Compare commits
139 Commits
b9da5b6d84
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 83b4d78cb7 | |||
| 9c91d778d9 | |||
| 6e43cc7383 | |||
| 877b97ec78 | |||
| e840ef4be3 | |||
| 56d5df0ab4 | |||
| 028d4c3594 | |||
| 9f262a21b0 | |||
| 7863ab3825 | |||
| 3ba49e92a9 | |||
| 02055e8db3 | |||
| cc68839306 | |||
| 45196f352f | |||
| d456d3c86a | |||
| 0dfecb3c14 | |||
| 3ca19724a5 | |||
| 3316ff99f9 | |||
| 53b71639ad | |||
| 07664bd743 | |||
| bb46e21c9b | |||
| 88f2f7c4e1 | |||
| bfa7dba4de | |||
| 271ee25d99 | |||
| d8b370fd0a | |||
| 86637f8eee | |||
| c49363fccc | |||
| 33a6c61ca6 | |||
| 33a106732f | |||
| 3011aa77da | |||
| ba36a28453 | |||
| 999788b790 | |||
| 775960c8c8 | |||
| b687e7fa6f | |||
| 4d4d5f437a | |||
| 5b114baa87 | |||
| c2e7064238 | |||
| dc9fdd3a38 | |||
| 58ea21df80 | |||
| 8c0f1ff6f3 | |||
| 3db1dd99b5 | |||
| 57b64523fb | |||
| a13ea3b9d1 | |||
| 3f23ca1bc6 | |||
| c1f5b3bdee | |||
| 761c483474 | |||
| c57617f611 | |||
| 3f18ba3b35 | |||
| 8527c369ee | |||
| bd3dc50100 | |||
| 700e3ca2c2 | |||
| ccc49d3a8f | |||
| 3e0a357441 | |||
| dc20033a93 | |||
| b86181eb6c | |||
| 9118f824fa | |||
| db89978871 | |||
| 4ac4e5cc44 | |||
| a6ae6166a4 | |||
| 4f8bec7419 | |||
| 52380a233e | |||
| 8b77e83f0a | |||
| dbb8f915e2 | |||
| e5e9a9931e | |||
| 144dbbd700 | |||
| 7650c339a2 | |||
| 69c971708a | |||
| 8951c624fe | |||
| 1a2ee5e07f | |||
| 9b149d4bfd | |||
| abc8af5f7e | |||
| ac7f77d86d | |||
| 719ff649a8 | |||
| 8af8af90d0 | |||
| cd0fd390a8 | |||
| c67bec095c | |||
| bcb7675a0d | |||
| 54d84b52cb | |||
| b790e7eb30 | |||
| e2895b5d2b | |||
| 2b79680167 | |||
| 39d73e91b4 | |||
| 7ddf0e38ee | |||
| b0fde3ee60 | |||
| 89c7964237 | |||
| 146f2e4a5e | |||
| 5c69f77b45 | |||
| 3921c5ffc7 | |||
| 93f796207f | |||
| b98a658831 | |||
| 06792d862e | |||
| 95daa5c040 | |||
| 3a7e8ccba4 | |||
| a29b5e22f2 | |||
| b309e7fd49 | |||
| 330ecfb6a6 | |||
| 7d8d599030 | |||
| d9dc55f841 | |||
| 81307cec47 | |||
| 59331e522d | |||
| b3253f35ee | |||
| 30ee857d62 | |||
| 38f6e525af | |||
| 37331d53ef | |||
| 5aeeb1cad1 | |||
| 4da81c9e4e | |||
| 7bf83bf46a | |||
| 1161645415 | |||
| 5913da53c5 | |||
| 8ea53f4003 | |||
| 9366ba7879 | |||
| c5bad996a7 | |||
| 0b1742770a | |||
| 2829d5ec1c | |||
| 58c744fd2f | |||
| a34a7a995f | |||
| 92fc250b54 | |||
| 2d911909f8 | |||
| 1a8fdf4225 | |||
| 336208004c | |||
| 03822389a1 | |||
| be4099486c | |||
| 2c0b214137 | |||
| b492f5f7b0 | |||
| e877e5b8ff | |||
| fad30d5461 | |||
| 261277fd51 | |||
| 7e60f5a0e6 | |||
| 1953e559f9 | |||
| f521aab97b | |||
| fb6298a9a1 | |||
| f2372eff9e | |||
| 78d4e979e5 | |||
| d6ce6128cf | |||
| 368adf2ebc | |||
| a637017900 | |||
| d0ff8b5738 | |||
| ac14f8d6a4 | |||
| ceb129c7d1 | |||
| f49637b5cc |
159
.claude/commands/atocore-context.md
Normal file
159
.claude/commands/atocore-context.md
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
description: Pull a context pack from the live AtoCore service for the current prompt
|
||||
argument-hint: <prompt text> [project-id]
|
||||
---
|
||||
|
||||
You are about to enrich a user prompt with context from the live
|
||||
AtoCore service. This is the daily-use entry point for AtoCore from
|
||||
inside Claude Code.
|
||||
|
||||
The work happens via the **shared AtoCore operator client** at
|
||||
`scripts/atocore_client.py`. That client is the canonical Python
|
||||
backbone for stable AtoCore operations and is meant to be reused by
|
||||
every LLM client (OpenClaw helper, future Codex skill, etc.) — see
|
||||
`docs/architecture/llm-client-integration.md` for the layering. This
|
||||
slash command is a thin Claude Code-specific frontend on top of it.
|
||||
|
||||
## Step 1 — parse the arguments
|
||||
|
||||
The user invoked `/atocore-context` with:
|
||||
|
||||
```
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You need to figure out two things:
|
||||
|
||||
1. The **prompt text** — what AtoCore will retrieve context for
|
||||
2. An **optional project hint** — used to scope retrieval to a
|
||||
specific project's trusted state and corpus
|
||||
|
||||
The user may have passed a project id or alias as the **last
|
||||
whitespace-separated token**. Don't maintain a hardcoded list of
|
||||
known aliases — let the shared client decide. Use this rule:
|
||||
|
||||
- Take the last token of `$ARGUMENTS`. Call it `MAYBE_HINT`.
|
||||
- Run `python scripts/atocore_client.py detect-project "$MAYBE_HINT"`
|
||||
to ask the registry whether it's a known project id or alias.
|
||||
This call is cheap (it just hits `/projects` and does a regex
|
||||
match) and inherits the client's fail-open behavior.
|
||||
- If the response has a non-null `matched_project`, the last
|
||||
token was an explicit project hint. `PROMPT_TEXT` is everything
|
||||
except the last token; `PROJECT_HINT` is the matched canonical
|
||||
project id.
|
||||
- Otherwise the last token is just part of the prompt.
|
||||
`PROMPT_TEXT` is the full `$ARGUMENTS`; `PROJECT_HINT` is empty.
|
||||
|
||||
This delegates the alias-knowledge to the registry instead of
|
||||
embedding a stale list in this markdown file. When you add a new
|
||||
project to the registry, the slash command picks it up
|
||||
automatically with no edits here.
|
||||
|
||||
## Step 2 — call the shared client for the context pack
|
||||
|
||||
The server resolves project hints through the registry before
|
||||
looking up trusted state, so you can pass either the canonical id
|
||||
or any alias to `context-build` and the trusted state lookup will
|
||||
work either way. (Regression test:
|
||||
`tests/test_context_builder.py::test_alias_hint_resolves_through_registry`.)
|
||||
|
||||
**If `PROJECT_HINT` is non-empty**, call `context-build` directly
|
||||
with that hint:
|
||||
|
||||
```bash
|
||||
python scripts/atocore_client.py context-build \
|
||||
"$PROMPT_TEXT" \
|
||||
"$PROJECT_HINT"
|
||||
```
|
||||
|
||||
**If `PROJECT_HINT` is empty**, do the 2-step fallback dance so the
|
||||
user always gets a context pack regardless of whether the prompt
|
||||
implies a project:
|
||||
|
||||
```bash
|
||||
# Try project auto-detection first.
|
||||
RESULT=$(python scripts/atocore_client.py auto-context "$PROMPT_TEXT")
|
||||
|
||||
# If auto-context could not detect a project it returns a small
|
||||
# {"status": "no_project_match", ...} envelope. In that case fall
|
||||
# back to a corpus-wide context build with no project hint, which
|
||||
# is the right behaviour for cross-project or generic prompts like
|
||||
# "what changed in AtoCore backup policy this week?"
|
||||
if echo "$RESULT" | grep -q '"no_project_match"'; then
|
||||
RESULT=$(python scripts/atocore_client.py context-build "$PROMPT_TEXT")
|
||||
fi
|
||||
|
||||
echo "$RESULT"
|
||||
```
|
||||
|
||||
This is the fix for the P2 finding from codex's review: previously
|
||||
the slash command sent every no-hint prompt through `auto-context`
|
||||
and returned `no_project_match` to the user with no context, even
|
||||
though the underlying client's `context-build` subcommand has
|
||||
always supported corpus-wide context builds.
|
||||
|
||||
In both branches the response is the JSON payload from
|
||||
`/context/build` (or, in the rare case where even the corpus-wide
|
||||
build fails, a `{"status": "unavailable"}` envelope from the
|
||||
client's fail-open layer).
|
||||
|
||||
## Step 3 — present the context pack to the user
|
||||
|
||||
The successful response contains at least:
|
||||
|
||||
- `formatted_context` — the assembled context block AtoCore would
|
||||
feed an LLM
|
||||
- `chunks_used`, `total_chars`, `budget`, `budget_remaining`,
|
||||
`duration_ms`
|
||||
- `chunks` — array of source documents that contributed, each with
|
||||
`source_file`, `heading_path`, `score`
|
||||
|
||||
Render in this order:
|
||||
|
||||
1. A one-line stats banner: `chunks=N, chars=X/budget, duration=Yms`
|
||||
2. The `formatted_context` block verbatim inside a fenced text code
|
||||
block so the user can read what AtoCore would feed an LLM
|
||||
3. The `chunks` array as a small bullet list with `source_file`,
|
||||
`heading_path`, and `score` per chunk
|
||||
|
||||
Two special cases:
|
||||
|
||||
- **`{"status": "unavailable"}`** (fail-open from the client)
|
||||
→ Tell the user: "AtoCore is unreachable at `$ATOCORE_BASE_URL`.
|
||||
Check `python scripts/atocore_client.py health` for diagnostics."
|
||||
- **Empty `chunks_used: 0` with no project state and no memories**
|
||||
→ Tell the user: "AtoCore returned no context for this prompt —
|
||||
either the corpus does not have relevant information or the
|
||||
project hint is wrong. Try a different hint or a longer prompt."
|
||||
|
||||
## Step 4 — what about capturing the interaction
|
||||
|
||||
Capture (Phase 9 Commit A) and the rest of the reflection loop
|
||||
(reinforcement, extraction, review queue) are intentionally NOT
|
||||
exposed by the shared client yet. The contracts are stable but the
|
||||
workflow ergonomics are not, so the daily-use slash command stays
|
||||
focused on context retrieval until those review flows have been
|
||||
exercised in real use. See `docs/architecture/llm-client-integration.md`
|
||||
for the deferral rationale.
|
||||
|
||||
When capture is added to the shared client, this slash command will
|
||||
gain a follow-up `/atocore-record-response` companion command that
|
||||
posts the LLM's response back to the same interaction. That work is
|
||||
queued.
|
||||
|
||||
## Notes for the assistant
|
||||
|
||||
- DO NOT bypass the shared client by calling curl yourself. The
|
||||
client is the contract between AtoCore and every LLM frontend; if
|
||||
you find a missing capability, the right fix is to extend the
|
||||
client, not to work around it.
|
||||
- DO NOT maintain a hardcoded list of project aliases in this
|
||||
file. Use `detect-project` to ask the registry — that's the
|
||||
whole point of having a registry.
|
||||
- DO NOT silently change `ATOCORE_BASE_URL`. If the env var points
|
||||
at the wrong instance, surface the error so the user can fix it.
|
||||
- DO NOT hide the formatted context pack from the user. Showing
|
||||
what AtoCore would feed an LLM is the whole point.
|
||||
- The output goes into the user's working context as background;
|
||||
they may follow up with their actual question, and the AtoCore
|
||||
context pack acts as informal injected knowledge.
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -6,8 +6,11 @@ __pycache__/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
venv/
|
||||
.venv/
|
||||
.claude/
|
||||
.claude/*
|
||||
!.claude/commands/
|
||||
!.claude/commands/**
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Session protocol (read first, every session)
|
||||
|
||||
**Before doing anything else, read `DEV-LEDGER.md` at the repo root.** It is the one-file source of truth for "what is currently true" — live SHA, active plan, open review findings, recent decisions. The narrative docs under `docs/` may lag; the ledger does not.
|
||||
|
||||
**Before ending a session, append a Session Log line to `DEV-LEDGER.md`** with what you did and which commit range it covers, and bump the Orientation section if anything there changed.
|
||||
|
||||
This rule applies equally to Claude, Codex, and any future agent working in this repo.
|
||||
|
||||
## Project role
|
||||
This repository is AtoCore, the runtime and machine-memory layer of the Ato ecosystem.
|
||||
|
||||
|
||||
30
CLAUDE.md
Normal file
30
CLAUDE.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# CLAUDE.md — project instructions for AtoCore
|
||||
|
||||
## Session protocol
|
||||
|
||||
Before doing anything else in this repo, read `DEV-LEDGER.md` at the repo root. It is the shared operating memory between Claude, Codex, and the human operator — live Dalidou SHA, active plan, open P1/P2 review findings, recent decisions, and session log. The narrative docs under `docs/` sometimes lag; the ledger does not.
|
||||
|
||||
Before ending a session, append a Session Log line to `DEV-LEDGER.md` covering:
|
||||
|
||||
- which commits you produced (sha range)
|
||||
- what changed at a high level
|
||||
- any harness / test count deltas
|
||||
- anything you overclaimed and later corrected
|
||||
|
||||
Bump the **Orientation** section if `live_sha`, `main_tip`, `test_count`, or `harness` changed.
|
||||
|
||||
`AGENTS.md` at the repo root carries the broader project principles (storage separation, deployment model, coding guidance). Read it when you need the "why" behind a constraint.
|
||||
|
||||
## Deploy workflow
|
||||
|
||||
```bash
|
||||
git push origin main && ssh papa@dalidou "bash /srv/storage/atocore/app/deploy/dalidou/deploy.sh"
|
||||
```
|
||||
|
||||
The deploy script self-verifies via `/health` build_sha — if it exits non-zero, do not assume the change is live.
|
||||
|
||||
## Working model
|
||||
|
||||
- Claude builds; Codex audits. No parallel work on the same files.
|
||||
- P1 review findings block further `main` commits until acknowledged in the ledger's **Open Review Findings** table.
|
||||
- Codex branches must fork from `origin/main` (no orphan commits that require `--allow-unrelated-histories`).
|
||||
232
DEV-LEDGER.md
Normal file
232
DEV-LEDGER.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# AtoCore Dev Ledger
|
||||
|
||||
> Shared operating memory between humans, Claude, and Codex.
|
||||
> **Every session MUST read this file at start and append a Session Log entry before ending.**
|
||||
> Section headers are stable - do not rename them. Trim Session Log and Recent Decisions to the last 20 entries at session end; older history lives in `git log` and `docs/`.
|
||||
|
||||
## Orientation
|
||||
|
||||
- **live_sha** (Dalidou `/health` build_sha): `775960c` (verified 2026-04-16 via /health, build_time 2026-04-16T17:59:30Z)
|
||||
- **last_updated**: 2026-04-18 by Claude (Phase 7A — Memory Consolidation "sleep cycle" V1 on branch, not yet deployed)
|
||||
- **main_tip**: `999788b`
|
||||
- **test_count**: 395 (21 new Phase 7A dedup tests + accumulated Phase 5/6 tests since last ledger refresh)
|
||||
- **harness**: `17/18 PASS` on live Dalidou (p04-constraints expects "Zerodur" — retrieval content gap, not regression)
|
||||
- **vectors**: 33,253
|
||||
- **active_memories**: 84 (31 project, 23 knowledge, 10 episodic, 8 adaptation, 7 preference, 5 identity)
|
||||
- **candidate_memories**: 2
|
||||
- **interactions**: 234 total (192 claude-code, 38 openclaw, 4 test)
|
||||
- **registered_projects**: atocore, p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, abb-space (aliased p08)
|
||||
- **project_state_entries**: 110 total (atocore=47, p06=19, p05=18, p04=15, abb=6, atomizer=5)
|
||||
- **entities**: 35 (engineering knowledge graph, Layer 2)
|
||||
- **off_host_backup**: `papa@192.168.86.39:/home/papa/atocore-backups/` via cron, verified
|
||||
- **nightly_pipeline**: backup → cleanup → rsync → OpenClaw import → vault refresh → extract → auto-triage → **auto-promote/expire (NEW)** → weekly synth/lint Sundays → **retrieval harness (NEW)** → **pipeline summary (NEW)**
|
||||
- **capture_clients**: claude-code (Stop hook + cwd project inference), openclaw (before_agent_start + llm_output plugin, verified live)
|
||||
- **wiki**: http://dalidou:8100/wiki (browse), /wiki/projects/{id}, /wiki/entities/{id}, /wiki/search
|
||||
- **dashboard**: http://dalidou:8100/admin/dashboard (now shows pipeline health, interaction totals by client, all registered projects)
|
||||
|
||||
## Active Plan
|
||||
|
||||
**Mini-phase**: Extractor improvement (eval-driven) + retrieval harness expansion.
|
||||
**Duration**: 8 days, hard gates at each day boundary.
|
||||
**Plan author**: Codex (2026-04-11). **Executor**: Claude. **Audit**: Codex.
|
||||
|
||||
### Preflight (before Day 1)
|
||||
|
||||
Stop if any of these fail:
|
||||
|
||||
- `git rev-parse HEAD` on `main` matches the expected branching tip
|
||||
- Live `/health` on Dalidou reports the SHA you think is deployed
|
||||
- `python scripts/retrieval_eval.py --json` still passes at the current baseline
|
||||
- `batch-extract` over the known 42-capture slice reproduces the current low-yield baseline
|
||||
- A frozen sample set exists for extractor labeling so the target does not move mid-phase
|
||||
|
||||
Success: baseline eval output saved, baseline extract output saved, working branch created from `origin/main`.
|
||||
|
||||
### Day 1 - Labeled extractor eval set
|
||||
|
||||
Pick 30 real captures: 10 that should produce 0 candidates, 10 that should plausibly produce 1, 10 ambiguous/hard. Store as a stable artifact (interaction id, expected count, expected type, notes). Add a runner that scores extractor output against labels.
|
||||
|
||||
Success: 30 labeled interactions in a stable artifact, one-command precision/recall output.
|
||||
Fail-early: if labeling 30 takes more than a day because the concept is unclear, tighten the extraction target before touching code.
|
||||
|
||||
### Day 2 - Measure current extractor
|
||||
|
||||
Run the rule-based extractor on all 30. Record yield, TP, FP, FN. Bucket misses by class (conversational preference, decision summary, status/constraint, meta chatter).
|
||||
|
||||
Success: short scorecard with counts by miss type, top 2 miss classes obvious.
|
||||
Fail-early: if the labeled set shows fewer than 5 plausible positives total, the corpus is too weak - relabel before tuning.
|
||||
|
||||
### Day 3 - Smallest rule expansion for top miss class
|
||||
|
||||
Add 1-2 narrow, explainable rules for the worst miss class. Add unit tests from real paraphrase examples in the labeled set. Then rerun eval.
|
||||
|
||||
Success: recall up on the labeled set, false positives do not materially rise, new tests cover the new cue class.
|
||||
Fail-early: if one rule expansion raises FP above ~20% of extracted candidates, revert or narrow before adding more.
|
||||
|
||||
### Day 4 - Decision gate: more rules or LLM-assisted prototype
|
||||
|
||||
If rule expansion reaches a **meaningfully reviewable queue**, keep going with rules. Otherwise prototype an LLM-assisted extraction mode behind a flag.
|
||||
|
||||
"Meaningfully reviewable queue":
|
||||
- >= 15-25% candidate yield on the 30 labeled captures
|
||||
- FP rate low enough that manual triage feels tolerable
|
||||
- >= 2 real non-synthetic candidates worth review
|
||||
|
||||
Hard stop: if candidate yield is still under 10% after this point, stop rule tinkering and switch to architecture review (LLM-assisted OR narrower extraction scope).
|
||||
|
||||
### Day 5 - Stabilize and document
|
||||
|
||||
Add remaining focused rules or the flagged LLM-assisted path. Write down in-scope and out-of-scope utterance kinds.
|
||||
|
||||
Success: labeled eval green against target threshold, extractor scope explainable in <= 5 bullets.
|
||||
|
||||
### Day 6 - Retrieval harness expansion (6 -> 15-20 fixtures)
|
||||
|
||||
Grow across p04/p05/p06. Include short ambiguous prompts, cross-project collision cases, expected project-state wins, expected project-memory wins, and 1-2 "should fail open / low confidence" cases.
|
||||
|
||||
Success: >= 15 fixtures, each active project has easy + medium + hard cases.
|
||||
Fail-early: if fixtures are mostly obvious wins, add harder adversarial cases before claiming coverage.
|
||||
|
||||
### Day 7 - Regression pass and calibration
|
||||
|
||||
Run harness on current code vs live Dalidou. Inspect failures (ranking, ingestion gap, project bleed, budget). Make at most ONE ranking/budget tweak if the harness clearly justifies it. Do not mix harness expansion and ranking changes in a single commit unless tightly coupled.
|
||||
|
||||
Success: harness still passes or improves after extractor work; any ranking tweak is justified by a concrete fixture delta.
|
||||
Fail-early: if > 20-25% of harness fixtures regress after extractor changes, separate concerns before merging.
|
||||
|
||||
### Day 8 - Merge and close
|
||||
|
||||
Clean commit sequence. Save before/after metrics (extractor scorecard, harness results). Update docs only with claims the metrics support.
|
||||
|
||||
Merge order: labeled corpus + runner -> extractor improvements + tests -> harness expansion -> any justified ranking tweak -> docs sync last.
|
||||
|
||||
Success: point to a before/after delta for both extraction and retrieval; docs do not overclaim.
|
||||
|
||||
### Hard Gates (stop/rethink points)
|
||||
|
||||
- Extractor yield < 10% after 30 labeled interactions -> stop, reconsider rule-only extraction
|
||||
- FP rate > 20% on labeled set -> narrow rules before adding more
|
||||
- Harness expansion finds < 3 genuinely hard cases -> harness still too soft
|
||||
- Ranking change improves one project but regresses another -> do not merge without explicit tradeoff note
|
||||
|
||||
### Branching
|
||||
|
||||
One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-harness-expansion` for Day 6-7. Keeps extraction and retrieval judgments auditable.
|
||||
|
||||
## Review Protocol
|
||||
|
||||
- Codex records review findings in **Open Review Findings**.
|
||||
- Claude must read **Open Review Findings** at session start before coding.
|
||||
- Codex owns finding text. Claude may update operational fields only:
|
||||
- `status`
|
||||
- `owner`
|
||||
- `resolved_by`
|
||||
- If Claude disagrees with a finding, do not rewrite it. Mark it `declined` and explain why in the **Session Log**.
|
||||
- Any commit or session that addresses a finding should reference the finding id in the commit message or **Session Log**.
|
||||
- `P1` findings block further commits in the affected area until they are at least acknowledged and explicitly tracked.
|
||||
- Findings may be code-level, claim-level, or ops-level. If the implementation boundary changes, retarget the finding instead of silently closing it.
|
||||
|
||||
## Open Review Findings
|
||||
|
||||
| id | finder | severity | file:line | summary | status | owner | opened_at | resolved_by |
|
||||
|-----|--------|----------|------------------------------------|-------------------------------------------------------------------------|--------------|--------|------------|-------------|
|
||||
| R1 | Codex | P1 | deploy/hooks/capture_stop.py:76-85 | Live Claude capture still omits `extract`, so "loop closed both sides" remains overstated in practice even though the API supports it | fixed | Claude | 2026-04-11 | c67bec0 |
|
||||
| R2 | Codex | P1 | src/atocore/context/builder.py | Project memories excluded from pack | fixed | Claude | 2026-04-11 | 8ea53f4 |
|
||||
| R3 | Claude | P2 | src/atocore/memory/extractor.py | Rule cues (`## Decision:`) never fire on conversational LLM text | declined | Claude | 2026-04-11 | see 2026-04-14 session log |
|
||||
| R4 | Codex | P2 | DEV-LEDGER.md:11 | Orientation `main_tip` was stale versus `HEAD` / `origin/main` | fixed | Codex | 2026-04-11 | 81307ce |
|
||||
| R5 | Codex | P1 | src/atocore/interactions/service.py:157-174 | The deployed extraction path still calls only the rule extractor; the new LLM extractor is eval/script-only, so Day 4 "gate cleared" is true as a benchmark result but not as an operational extraction path | fixed | Claude | 2026-04-12 | c67bec0 |
|
||||
| R6 | Codex | P1 | src/atocore/memory/extractor_llm.py:258-276 | LLM extraction accepts model-supplied `project` verbatim with no fallback to `interaction.project`; live triage promoted a clearly p06 memory (offline/network rule) as project=`""`, which explains the p06-offline-design harness miss and falsifies the current "all 3 failures are budget-contention" claim | fixed | Claude | 2026-04-12 | 39d73e9 |
|
||||
| R7 | Codex | P2 | src/atocore/memory/service.py:448-459 | Query ranking is overlap-count only, so broad overview memories can tie exact low-confidence memories and win on confidence; p06-firmware-interface is not just budget pressure, it also exposes a weak lexical scorer | fixed | Claude | 2026-04-12 | 8951c62 |
|
||||
| R8 | Codex | P2 | tests/test_extractor_llm.py:1-7 | LLM extractor tests stop at parser/failure contracts; there is no automated coverage for the script-only persistence/review path that produced the 16 promoted memories, including project-scope preservation | fixed | Claude | 2026-04-12 | 69c9717 |
|
||||
| R9 | Codex | P2 | src/atocore/memory/extractor_llm.py:258-259 | The R6 fallback only repairs empty project output. A wrong non-empty model project still overrides the interaction's known scope, so project attribution is improved but not yet trust-preserving. | fixed | Claude | 2026-04-12 | e5e9a99 |
|
||||
| R10 | Codex | P2 | docs/master-plan-status.md:31-33 | "Phase 8 - OpenClaw Integration" is fair as a baseline milestone, but not as a "primary" integration claim. `t420-openclaw/atocore.py` currently covers a narrow read-oriented subset (13 request shapes vs 32 API routes) plus fail-open health, while memory/interactions/admin write paths remain out of surface. | fixed | Claude | 2026-04-12 | (pending) |
|
||||
| R11 | Codex | P2 | src/atocore/api/routes.py:773-845 | `POST /admin/extract-batch` still accepts `mode="llm"` inside the container and returns a successful 0-candidate result instead of surfacing that host-only LLM extraction is unavailable from this runtime. That is a misleading API contract for operators. | fixed | Claude | 2026-04-12 | (pending) |
|
||||
| R12 | Codex | P2 | scripts/batch_llm_extract_live.py:39-190 | The host-side extractor duplicates the LLM system prompt and JSON parsing logic from `src/atocore/memory/extractor_llm.py`. It works today, but this is now a prompt/parser drift risk across the container and host implementations. | fixed | Claude | 2026-04-12 | (pending) |
|
||||
| R13 | Codex | P2 | DEV-LEDGER.md:12 | The new `286 passing` test-count claim is not reproducibly auditable from the current audit environments: neither Dalidou nor the clean worktree has `pytest` available. The claim may be true in Claude's dev shell, but it remains unverified in this audit. | fixed | Claude | 2026-04-12 | (pending) |
|
||||
|
||||
## Recent Decisions
|
||||
|
||||
- **2026-04-12** Day 4 gate cleared: LLM-assisted extraction via `claude -p` (OAuth, no API key) is the path forward. Rule extractor stays as default for structural cues. *Proposed by:* Claude. *Ratified by:* Antoine.
|
||||
- **2026-04-12** First live triage: 16 promoted, 35 rejected from 51 LLM-extracted candidates. 31% accept rate. Active memory count 20->36. *Executed by:* Claude. *Ratified by:* Antoine.
|
||||
- **2026-04-12** No API keys allowed in AtoCore — LLM-assisted features use OAuth via `claude -p` or equivalent CLI-authenticated paths. *Proposed by:* Antoine.
|
||||
- **2026-04-12** Multi-model extraction direction: extraction/triage should be model-agnostic, with Codex/Gemini/Ollama as second-pass reviewers for robustness. *Proposed by:* Antoine.
|
||||
- **2026-04-11** Adopt this ledger as shared operating memory between Claude and Codex. *Proposed by:* Antoine. *Ratified by:* Antoine.
|
||||
- **2026-04-11** Accept Codex's 8-day mini-phase plan verbatim as Active Plan. *Proposed by:* Codex. *Ratified by:* Antoine.
|
||||
- **2026-04-11** Review findings live in `DEV-LEDGER.md` with Codex owning finding text and Claude updating status fields only. *Proposed by:* Codex. *Ratified by:* Antoine.
|
||||
- **2026-04-11** Project memories land in the pack under `--- Project Memories ---` at 25% budget ratio, gated on canonical project hint. *Proposed by:* Claude.
|
||||
- **2026-04-11** Extraction stays off the capture hot path. Batch / manual only. *Proposed by:* Antoine.
|
||||
- **2026-04-11** 4-step roadmap: extractor -> harness expansion -> Wave 2 ingestion -> OpenClaw finish. Steps 1+2 as one mini-phase. *Ratified by:* Antoine.
|
||||
- **2026-04-11** Codex branches must fork from `main`, not be orphan commits. *Proposed by:* Claude. *Agreed by:* Codex.
|
||||
|
||||
## Session Log
|
||||
|
||||
- **2026-04-19 Claude** Shipped Phases 7A.1 (tiered auto-merge), 7C (tag canonicalization), 7D (confidence decay), 7I (OpenClaw context injection), UI refresh (memory/domain/activity pages + topnav), and closed the Claude Code retrieval asymmetry. Builds deployed: `028d4c3` → `56d5df0` → `e840ef4` → `877b97e` → `6e43cc7` → `9c91d77`. New capture-surface scope: Claude Code (Stop + UserPromptSubmit hooks, both installed and verified live) + OpenClaw (v0.2.0 plugin with capture + context injection, verified loaded on T420 gateway). `/wiki/capture` paste form removed from topnav; kept as labeled fallback. Anthropic API polling explicitly out of scope per user. Tests 414 → 459. `docs/capture-surfaces.md` documents the sanctioned scope.
|
||||
|
||||
- **2026-04-18 Claude** **Phase 7A — Memory Consolidation V1 ("sleep cycle") landed on branch.** New `docs/PHASE-7-MEMORY-CONSOLIDATION.md` covers all 8 subphases (7A dedup, 7B contradictions, 7C tag canon, 7D confidence decay, 7E memory detail, 7F domain view, 7F re-extract, 7H vector hygiene). 7A implementation: schema migration `memory_merge_candidates`, `atocore.memory.similarity` (cosine + transitive cluster), stdlib-only `atocore.memory._dedup_prompt` (llm drafts unified content preserving all specifics), `merge_memories()` + `create_merge_candidate()` + `get_merge_candidates()` + `reject_merge_candidate()` in service.py, host-side `scripts/memory_dedup.py` (HTTP + claude -p, idempotent via sorted-id set), 5 new endpoints under `/admin/memory/merge-candidates*` + `/admin/memory/dedup-scan` + `/admin/memory/dedup-status`, purple-themed "🔗 Merge Candidates" section in /admin/triage with editable draft + approve/reject buttons, "🔗 Scan for duplicates" control bar with threshold slider, nightly Step B3 in batch-extract.sh (0.90 daily, 0.85 Sundays deep), `deploy/dalidou/dedup-watcher.sh` host watcher for UI-triggered scans (mirrors graduation-watcher pattern). 21 new tests (similarity, prompt parse, idempotency, merge happy path, override content/tags, audit rows, abort-if-source-tampered, reject leaves sources alone, schema). Tests 374 → 395. Not yet deployed; harness not re-run. Next: push + deploy, install `dedup-watcher.sh` in host cron, trigger first scan, review proposals in UI.
|
||||
|
||||
- **2026-04-16 Claude** `b687e7f..999788b` **"Make It Actually Useful" sprint.** Two-part session: ops fixes then consolidation sprint.
|
||||
|
||||
**Part 1 — Ops fixes:** Deployed `b687e7f` (project inference from cwd). Fixed cron logging (was `/dev/null` — redirected to `~/atocore-logs/`). Fixed OpenClaw gateway crash-loop (`discord.replyToMode: "any"` invalid → `"all"`). Deployed `atocore-capture` plugin on T420 OpenClaw using `before_agent_start` + `llm_output` hooks — verified end-to-end: 38 `client=openclaw` interactions captured. Backfilled project tags on 179/181 unscoped interactions (165 atocore, 8 p06, 6 p04).
|
||||
|
||||
**Part 2 — Sprint (Phase A+C):** Pipeline observability: retrieval harness now runs nightly (Step E), pipeline summary persisted to project state (Step F), dashboard enhanced with interaction totals by client + pipeline health section + dynamic project list. Phase 10 landed: `auto_promote_reinforced()` (candidate→active when reference_count≥3, confidence≥0.7) + `expire_stale_candidates()` (14-day unreinforced→auto-reject), both wired into nightly cron Step B2. Seeding script created (26 entries across 6 projects — all already existed from prior session). Tests 299→303. Harness 17/18 on live Dalidou (p04-constraints expects "Zerodur" — retrieval content gap, not regression). Deployed `775960c`.
|
||||
|
||||
- **2026-04-15 Claude (pm)** Closed the last harness failure honestly. **p06-tailscale fixed: 18/18 PASS.** Root-caused: not a retrieval bug — the p06 `ARCHITECTURE.md` Overview chunk legitimately mentions "the GigaBIT M1 telescope mirror" because the Polisher Suite is built *for* that mirror. All four retrieved sources for the tailscale prompt were genuinely p06/shared paths; zero actual p04 chunks leaked. The fixture's `expect_absent: GigaBIT` was catching semantic overlap, not retrieval bleed. Narrowed it to `expect_absent: "[Source: p04-gigabit/"` — a source-path check that tests the real invariant (no p04 source chunks in p06 context). Other p06 fixtures still use the word-blacklist form; they pass today because their more-specific prompts don't pull the ARCHITECTURE.md Overview, so I left them alone rather than churn fixtures that aren't failing. Did NOT change retrieval/ranking — no code change, fixture-only fix. Tests unchanged at 299.
|
||||
|
||||
- **2026-04-15 Claude** Deploy + doc debt sweep. Deployed `c2e7064` to Dalidou (build_time 2026-04-15T15:08:51Z, build_sha matches, /health ok) so R11/R12 are now live, not just on main. **R11 verified on live**: `POST /admin/extract-batch {"mode":"llm"}` against http://127.0.0.1:8100 returns HTTP 503 with the operator-facing "claude CLI not on PATH, run host-side script or use mode=rule" message — exactly the post-fix contract. **R13 closed (fixed)**: added a reproduction recipe to Quick Commands (`pip install -r requirements-dev.txt && pytest --collect-only -q && pytest -q`) and re-cited `test_count: 299` against a fresh local collection on 2026-04-15, so the claim is now auditable from any clean checkout — Codex's audit worktree just needs `pip install -r requirements-dev.txt`. **R10 closed (fixed)**: rewrote the `docs/master-plan-status.md` OpenClaw section to explicitly disclaim "primary integration" and report the current narrow surface: 14 client request shapes against ~44 server routes, predominantly read + `/project/state` + `/ingest/sources`, with memory/interactions/admin/entities/triage/extraction writes correctly out of scope. Open findings now: none blocking. Next natural move: the last harness failure `p06-tailscale` (chunk bleed).
|
||||
|
||||
- **2026-04-14 Claude (pm)** Closed R11+R12, declined R3. **R11 (fixed):** `POST /admin/extract-batch` with `mode="llm"` now returns 503 when the `claude` CLI is not on PATH, with a message pointing at the host-side script. Previously it silently returned a success-0 payload, masking host-vs-container truth. 2 new tests in `test_extraction_pipeline.py` cover the 503 path and the rule-mode-still-works path. **R12 (fixed):** extracted shared `SYSTEM_PROMPT` + `parse_llm_json_array` + `normalize_candidate_item` + `build_user_message` into stdlib-only `src/atocore/memory/_llm_prompt.py`. Both `src/atocore/memory/extractor_llm.py` (container) and `scripts/batch_llm_extract_live.py` (host) now import from it. The host script uses `sys.path` to reach the stdlib-only module without needing the full atocore package. Project-attribution policy stays path-specific (container uses registry-check; host defers to server). **R3 (declined):** rule cues not firing on conversational LLM text is by design now — the LLM extractor (llm-0.4.0) is the production path for conversational content as of the Day 4 gate (2026-04-12). Expanding rules to match conversational prose risks the FP blowup Day 2 already showed. Rule extractor stays narrow for structural PKM text. Tests 297 → 299. Live `/health` still `58ea21d`; this session's changes need deploy.
|
||||
|
||||
- **2026-04-14 Claude** MAJOR session: Engineering knowledge layer V1 (Layer 2) built — entity + relationship tables, 15 types, 12 relationship kinds, 35 bootstrapped entities across p04/p05/p06. Human Mirror (Layer 3) — GET /projects/{name}/mirror.html + navigable wiki at /wiki with search. Karpathy-inspired upgrades: contradiction detection in triage, weekly lint pass, weekly synthesis pass producing "current state" paragraphs at top of project pages. Auto-detection of new projects from extraction. Registry persistence fix (ATOCORE_PROJECT_REGISTRY_DIR env var). abb-space/p08 aliases added, atomizer-v2 ingested (568 docs, +12,472 vectors). Identity/preference seed (6 new), signal-aggressive extractor rewrite (llm-0.4.0), auto vault refresh in cron. **OpenClaw one-way pull importer** built per codex proposal — reads /home/papa/clawd SOUL.md, USER.md, MEMORY.md, MODEL-ROUTING.md, memory/*.md via SSH, hash-delta import, pipeline triages. First import: 10 candidates → 10 promoted with lenient triage rule. Active memories 47→84. State entries 61→78. Tests 290→297. Dashboard at /admin/dashboard. Wiki at /wiki.
|
||||
|
||||
|
||||
- **2026-04-12 Claude** `4f8bec7..4ac4e5c` Session close. Merged OpenClaw capture plugin, ingested atomizer-v2 (568 docs, 12,472 new vectors → 33,253 total), seeded Phase 4 identity/preference memories (6 new, 47 total active), added deeper Wave 2 state entries (p05 +3, p06 +3), fixed R9 project trust hierarchy (7 case tests), built auto-triage pipeline, observability dashboard at /admin/dashboard. Updated master-plan-status.md and DEV-LEDGER.md to reflect full current state. 7/14 phases baseline complete. All P1s closed. Nightly pipeline runs unattended with both Claude Code and OpenClaw feeding the reflection loop.
|
||||
- **2026-04-12 Codex (branch `codex/openclaw-capture-plugin`)** added a minimal external OpenClaw plugin at `openclaw-plugins/atocore-capture/` that mirrors Claude Code capture semantics: user-triggered assistant turns are POSTed to AtoCore `/interactions` with `client="openclaw"` and `reinforce=true`, fail-open, no extraction in-path. For live verification, temporarily added the local plugin load path to OpenClaw config and restarted the gateway so the plugin can load. Branch truth is ready; end-to-end verification still needs one fresh post-restart OpenClaw user turn to confirm new `client=openclaw` interactions appear on Dalidou.
|
||||
- **2026-04-12 Claude** Batch 3 (R9 fix): `144dbbd..e5e9a99`. Trust hierarchy for project attribution — interaction scope always wins when set, model project only used for unscoped interactions + registered check. 7 case tests (A-G) cover every combination. Harness 17/18 (no regression). Tests 286->290. Before: wrong registered project could silently override interaction scope. After: interaction.project is the strongest signal; model project is only a fallback for unscoped captures. Not yet guaranteed: nothing prevents the *same* project's model output from being semantically wrong within that project. R9 marked fixed.
|
||||
|
||||
- **2026-04-12 Codex (audit branch `codex/audit-batch2`)** audited `69c9717..origin/main` against the current branch tip and live Dalidou. Verified: live build is `8951c62`, retrieval harness improved to **17/18 PASS**, candidate queue is now empty, active memories rose to **41**, and `python3 scripts/auto_triage.py --dry-run --base-url http://127.0.0.1:8100` runs cleanly on Dalidou but only exercised the empty-queue path. Updated R7 to **fixed** (`8951c62`) and R8 to **fixed** (`69c9717`). Kept R9 **open** because project trust-preservation still allows a wrong non-empty registered project from the model to override the interaction scope. Added R13 because the new `286 passing` claim could not be independently reproduced in this audit: `pytest` is absent on both Dalidou and the clean audit worktree. Also corrected stale Orientation fields (live SHA, main tip, harness, active/candidate memory counts).
|
||||
- **2026-04-12 Codex (audit branch `codex/audit-2026-04-12-extraction`)** audited `54d84b5..ac7f77d` with live Dalidou verification. Confirmed the host-side LLM extraction pipeline is operational: nightly cron points at `deploy/dalidou/cron-backup.sh`, Step 4 calls `deploy/dalidou/batch-extract.sh`, the batch script exists/executable on Dalidou, and a manual host-side run produced candidates successfully. Updated R1 and R5 to **fixed** (`c67bec0`) because extraction now runs unattended off-container. Live state during audit: build `39d73e9`, active memories **36**, candidate queue **29** (16 existing + 13 added by manual verification run), and `last_extract_batch_run` populated in AtoCore project state. Added R11-R12 for the misleading container `mode=llm` no-op and host/container prompt-parser duplication. Security note: CLI positional prompt/response text is visible in process args while `claude -p` runs; acceptable on a single-user home host, but worth remembering if Dalidou's trust boundary changes.
|
||||
- **2026-04-12 Codex (audit branch `codex/audit-2026-04-12-final`)** audited `c5bad99..e2895b5` against origin/main, live Dalidou, and the OpenClaw client script. Live state checked: build `39d73e9`, harness reproducible at **16/18 PASS**, active memories **36**, and `t420-openclaw/atocore.py health` fails open correctly with `fail_open=true`. Spot-checks of Wave 2 project-state entries matched their cited vault docs. Updated R5-R8 status reality (R6 fixed by `39d73e9`), added R9-R10, and corrected Orientation `main_tip` to `e2895b5` because the ledger had drifted behind origin/main. Note: live Dalidou is still on `39d73e9`, so branch-truth and deploy-truth are not the same yet.
|
||||
- **2026-04-12 Claude** Wave 2 trusted operational ingestion + codex audit response. Read 6 vault docs, created 8 new Trusted Project State entries (p04 +2, p05 +3, p06 +3). Fixed R6 (project fallback in LLM extractor) per codex audit. Fixed misscoped p06 offline memory on live Dalidou. Merged codex/audit-2026-04-12. Switched default LLM model from haiku to sonnet. Harness 15/18 -> 16/18. Tests 278 -> 280. main_tip 146f2e4 -> 39d73e9.
|
||||
|
||||
- **2026-04-12 Codex (audit branch `codex/audit-2026-04-12`)** audited `c5bad99..146f2e4` against code, live Dalidou, and the 36 active memories. Confirmed: `claude -p` invocation is not shell-injection-prone (`subprocess.run(args)` with no shell), off-host backup wiring matches the ledger, and R1 remains unresolved in practice. Added R5-R8. Corrected Orientation `main_tip` (`146f2e4`, not `5c69f77`) and tightened the harness note: p06-firmware-interface is a ranking-tie issue, p06-offline-design comes from a project-scope miss in live triage, and p06-tailscale is retrieved-chunk bleed rather than memory-band budget contention.
|
||||
- **2026-04-12 Claude** `06792d8..5c69f77` Day 5-8 close. Documented extractor scope (5 in-scope, 6 out-of-scope categories). Expanded harness from 6 to 18 fixtures (p04 +1, p05 +1, p06 +7, adversarial +2). Per-entry memory cap at 250 chars fixed 1 of 4 budget-contention failures. Final harness: 15/18 PASS. Mini-phase complete. Before/after: rule extractor 0% recall -> LLM 100%; harness 6/6 -> 15/18; active memories 20 -> 36.
|
||||
- **2026-04-12 Claude** `330ecfb..06792d8` (merged eval-loop branch + triage). Day 1-4 of the mini-phase completed in one session. Day 2 baseline: rule extractor 0% recall, 5 distinct miss classes. Day 4 gate cleared: LLM extractor (claude -p haiku, OAuth) hit 100% recall, 2.55 yield/interaction. Refactored from anthropic SDK to subprocess after "no API key" rule. First live triage: 51 candidates -> 16 promoted, 35 rejected. Active memories 20->36. p06-polisher went from 2 to 16 memories (firmware/telemetry architecture set). POST /memory now accepts status field. Test count 264->278.
|
||||
- **2026-04-11 Claude** `claude/extractor-eval-loop @ 7d8d599` — Day 1+2 of the mini-phase. Froze a 64-interaction snapshot (`scripts/eval_data/interactions_snapshot_2026-04-11.json`) and labeled 20 by length-stratified random sample (5 positive, 15 zero; 7 total expected candidates). Built `scripts/extractor_eval.py` as a file-based eval runner. **Day 2 baseline: rule extractor hit 0% yield / 0% recall / 0% precision on the labeled set; 5 false negatives across 5 distinct miss classes (recommendation_prose, architectural_change_summary, spec_update_announcement, layered_recommendation, alignment_assertion).** This is the Day 4 hard-stop signal arriving two days early — a single rule expansion cannot close a 5-way miss, and widening rules blindly will collapse precision. The Day 4 decision gate is escalated to Antoine for ratification before Day 3 touches any extractor code. No extractor code on main has changed.
|
||||
- **2026-04-11 Codex (ledger audit)** fixed stale `main_tip`, retargeted R1 from the API surface to the live Claude Stop hook, and formalized the review write protocol so Claude can consume findings without rewriting them.
|
||||
- **2026-04-11 Claude** `b3253f3..59331e5` (1 commit). Wired the DEV-LEDGER, added session protocol to AGENTS.md, created project-local CLAUDE.md, deleted stale `codex/port-atocore-ops-client` remote branch. No code changes, no redeploy needed.
|
||||
- **2026-04-11 Claude** `c5bad99..b3253f3` (11 commits + 1 merge). Length-aware reinforcement, project memories in pack, query-relevance memory ranking, hyphenated-identifier tokenizer, retrieval eval harness seeded, off-host backup wired end-to-end, docs synced, codex integration-pass branch merged. Harness went 0->6/6 on live Dalidou.
|
||||
- **2026-04-11 Codex (async review)** identified 2 P1s against a stale checkout. R1 was fair (extraction not automated), R2 was outdated (project memories already landed on main). Delivered the 8-day execution plan now in Active Plan.
|
||||
- **2026-04-06 Antoine** created `codex/atocore-integration-pass` with the `t420-openclaw/` workspace (merged 2026-04-11).
|
||||
|
||||
## Working Rules
|
||||
|
||||
- Claude builds; Codex audits. No parallel work on the same files.
|
||||
- Codex branches fork from `main`: `git fetch origin && git checkout -b codex/<topic> origin/main`.
|
||||
- P1 findings block further main commits until acknowledged in Open Review Findings.
|
||||
- Every session appends at least one Session Log line and bumps Orientation.
|
||||
- Trim Session Log and Recent Decisions to the last 20 at session end.
|
||||
- Docs in `docs/` may overclaim stale status; the ledger is the one-file source of truth for "what is true right now."
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Check live state
|
||||
ssh papa@dalidou "curl -s http://localhost:8100/health"
|
||||
|
||||
# Run the retrieval harness
|
||||
python scripts/retrieval_eval.py # human-readable
|
||||
python scripts/retrieval_eval.py --json # machine-readable
|
||||
|
||||
# Deploy a new main tip
|
||||
git push origin main && ssh papa@dalidou "bash /srv/storage/atocore/app/deploy/dalidou/deploy.sh"
|
||||
|
||||
# Reflection-loop ops
|
||||
python scripts/atocore_client.py batch-extract '' '' 200 false # preview
|
||||
python scripts/atocore_client.py batch-extract '' '' 200 true # persist
|
||||
python scripts/atocore_client.py triage
|
||||
|
||||
# Reproduce the ledger's test_count claim from a clean checkout
|
||||
pip install -r requirements-dev.txt
|
||||
pytest --collect-only -q | tail -1 # -> "N tests collected"
|
||||
pytest -q # -> "N passed"
|
||||
```
|
||||
17
README.md
17
README.md
@@ -24,6 +24,10 @@ curl -X POST http://localhost:8100/context/build \
|
||||
|
||||
# CLI ingestion
|
||||
python scripts/ingest_folder.py --path /path/to/notes
|
||||
|
||||
# Live operator client
|
||||
python scripts/atocore_client.py health
|
||||
python scripts/atocore_client.py audit-query "gigabit" 5
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
@@ -66,10 +70,19 @@ pip install -e ".[dev]"
|
||||
pytest
|
||||
```
|
||||
|
||||
## Operations
|
||||
|
||||
- `scripts/atocore_client.py` provides a live API client for project refresh, project-state inspection, and retrieval-quality audits.
|
||||
- `docs/operations.md` captures the current operational priority order: retrieval quality, Wave 2 trusted-operational ingestion, AtoDrive scoping, and restore validation.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
Implementation-facing architecture notes live under `docs/architecture/`.
|
||||
|
||||
Current additions:
|
||||
- `docs/architecture/engineering-knowledge-hybrid-architecture.md`
|
||||
- `docs/architecture/engineering-ontology-v1.md`
|
||||
- `docs/architecture/engineering-knowledge-hybrid-architecture.md` — 5-layer hybrid model
|
||||
- `docs/architecture/engineering-ontology-v1.md` — V1 object and relationship inventory
|
||||
- `docs/architecture/engineering-query-catalog.md` — 20 v1-required queries
|
||||
- `docs/architecture/memory-vs-entities.md` — canonical home split
|
||||
- `docs/architecture/promotion-rules.md` — Layer 0 to Layer 2 pipeline
|
||||
- `docs/architecture/conflict-model.md` — contradictory facts detection and resolution
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
},
|
||||
{
|
||||
"id": "p06-polisher",
|
||||
"aliases": ["p06", "polisher"],
|
||||
"aliases": ["p06", "polisher", "p11", "polisher-fullum", "P11-Polisher-Fullum"],
|
||||
"description": "Active P06 polisher corpus from PKM, software-suite notes, and selected repo context.",
|
||||
"ingest_roots": [
|
||||
{
|
||||
@@ -47,6 +47,30 @@
|
||||
"label": "P06 staged project docs"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "abb-space",
|
||||
"aliases": ["abb", "abb-mirror", "p08", "p08-abb-space", "p08-abb-space-mirror"],
|
||||
"description": "ABB Space mirror - lead/proposition for Atomaste. Also tracked as P08.",
|
||||
"ingest_roots": [
|
||||
{
|
||||
"source": "vault",
|
||||
"subpath": "incoming/projects/abb-space",
|
||||
"label": "ABB Space docs"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "atomizer-v2",
|
||||
"aliases": ["atomizer", "aom", "aom-v2"],
|
||||
"description": "Atomizer V2 parametric optimization platform",
|
||||
"ingest_roots": [
|
||||
{
|
||||
"source": "vault",
|
||||
"subpath": "incoming/projects/atomizer-v2/repo",
|
||||
"label": "Atomizer V2 repo"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
108
deploy/dalidou/auto-triage-watcher.sh
Normal file
108
deploy/dalidou/auto-triage-watcher.sh
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deploy/dalidou/auto-triage-watcher.sh
|
||||
# --------------------------------------
|
||||
# Host-side watcher for on-demand auto-triage requests from the web UI.
|
||||
#
|
||||
# The web UI at /admin/triage has an "Auto-process queue" button that
|
||||
# POSTs to /admin/triage/request-drain, which writes a timestamp to
|
||||
# AtoCore project state (atocore/config/auto_triage_requested_at).
|
||||
#
|
||||
# This script runs on the Dalidou HOST (where the claude CLI is
|
||||
# available), polls for the flag, and runs auto_triage.py when seen.
|
||||
#
|
||||
# Installed via cron to run every 2 minutes:
|
||||
# */2 * * * * /srv/storage/atocore/app/deploy/dalidou/auto-triage-watcher.sh
|
||||
#
|
||||
# Safety:
|
||||
# - Lock file prevents concurrent runs
|
||||
# - Flag is cleared after processing so one request = one run
|
||||
# - If auto_triage hangs, the lock prevents pileup until manual cleanup
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ATOCORE_URL="${ATOCORE_URL:-http://127.0.0.1:8100}"
|
||||
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
LOCK_FILE="/tmp/atocore-auto-triage.lock"
|
||||
LOG_DIR="/home/papa/atocore-logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
log() { printf '[%s] %s\n' "$TS" "$*"; }
|
||||
|
||||
# Fetch the request flag via API (read-only, no lock needed)
|
||||
STATE_JSON=$(curl -sSf --max-time 5 "$ATOCORE_URL/project/state/atocore" 2>/dev/null || echo "{}")
|
||||
REQUESTED=$(echo "$STATE_JSON" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
for e in d.get('entries', d.get('state', [])):
|
||||
if e.get('category') == 'config' and e.get('key') == 'auto_triage_requested_at':
|
||||
print(e.get('value', ''))
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$REQUESTED" ]]; then
|
||||
# No request — silent exit
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Acquire lock (non-blocking)
|
||||
exec 9>"$LOCK_FILE" || exit 0
|
||||
if ! flock -n 9; then
|
||||
log "auto-triage already running, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Record we're starting
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"auto_triage_running\",\"value\":\"1\",\"source\":\"host watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"auto_triage_last_started_at\",\"value\":\"$TS\",\"source\":\"host watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
LOG_FILE="$LOG_DIR/auto-triage-ondemand-$(date -u +%Y%m%d-%H%M%S).log"
|
||||
log "Starting auto-triage (request: $REQUESTED, log: $LOG_FILE)"
|
||||
|
||||
# Clear the request flag FIRST so duplicate clicks queue at most one re-run
|
||||
# (the next watcher tick would then see a fresh request, not this one)
|
||||
curl -sSf -X DELETE "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"config\",\"key\":\"auto_triage_requested_at\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
# Run the drain
|
||||
cd "$APP_DIR"
|
||||
export PYTHONPATH="$APP_DIR/src:${PYTHONPATH:-}"
|
||||
if python3 scripts/auto_triage.py --base-url "$ATOCORE_URL" >> "$LOG_FILE" 2>&1; then
|
||||
RESULT_LINE=$(tail -5 "$LOG_FILE" | grep "total:" | tail -1 || tail -1 "$LOG_FILE")
|
||||
RESULT="${RESULT_LINE:-completed}"
|
||||
log "auto-triage finished: $RESULT"
|
||||
else
|
||||
RESULT="ERROR — see $LOG_FILE"
|
||||
log "auto-triage FAILED — see $LOG_FILE"
|
||||
fi
|
||||
|
||||
FINISH_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
# Mark done
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"auto_triage_running\",\"value\":\"0\",\"source\":\"host watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"auto_triage_last_finished_at\",\"value\":\"$FINISH_TS\",\"source\":\"host watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
# Escape quotes in result for JSON
|
||||
SAFE_RESULT=$(printf '%s' "$RESULT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read())[1:-1])")
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"auto_triage_last_result\",\"value\":\"$SAFE_RESULT\",\"source\":\"host watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
274
deploy/dalidou/batch-extract.sh
Normal file
274
deploy/dalidou/batch-extract.sh
Normal file
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deploy/dalidou/batch-extract.sh
|
||||
# --------------------------------
|
||||
# Host-side LLM batch extraction for Dalidou.
|
||||
#
|
||||
# The claude CLI is available on the Dalidou HOST but NOT inside the
|
||||
# Docker container. This script runs on the host, fetches recent
|
||||
# interactions from the AtoCore API, runs the LLM extractor locally
|
||||
# (claude -p sonnet), and posts candidates back to the API.
|
||||
#
|
||||
# Intended to be called from cron-backup.sh after backup/cleanup/rsync,
|
||||
# or manually via:
|
||||
#
|
||||
# bash /srv/storage/atocore/app/deploy/dalidou/batch-extract.sh
|
||||
#
|
||||
# Environment variables:
|
||||
# ATOCORE_URL default http://127.0.0.1:8100
|
||||
# ATOCORE_EXTRACT_LIMIT default 50
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ATOCORE_URL="${ATOCORE_URL:-http://127.0.0.1:8100}"
|
||||
LIMIT="${ATOCORE_EXTRACT_LIMIT:-50}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
log() { printf '[%s] %s\n' "$TIMESTAMP" "$*"; }
|
||||
|
||||
# The Python script needs the atocore source on PYTHONPATH
|
||||
export PYTHONPATH="$APP_DIR/src:${PYTHONPATH:-}"
|
||||
|
||||
log "=== AtoCore batch extraction + triage starting ==="
|
||||
log "URL=$ATOCORE_URL LIMIT=$LIMIT"
|
||||
|
||||
# --- Pipeline stats accumulator ---
|
||||
EXTRACT_OUT=""
|
||||
TRIAGE_OUT=""
|
||||
HARNESS_OUT=""
|
||||
|
||||
# Step A: Extract candidates from recent interactions
|
||||
log "Step A: LLM extraction"
|
||||
EXTRACT_OUT=$(python3 "$APP_DIR/scripts/batch_llm_extract_live.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
--limit "$LIMIT" \
|
||||
2>&1) || {
|
||||
log "WARN: batch extraction failed (non-blocking)"
|
||||
}
|
||||
echo "$EXTRACT_OUT"
|
||||
|
||||
# Step B: Auto-triage candidates in the queue
|
||||
log "Step B: auto-triage"
|
||||
TRIAGE_OUT=$(python3 "$APP_DIR/scripts/auto_triage.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
2>&1) || {
|
||||
log "WARN: auto-triage failed (non-blocking)"
|
||||
}
|
||||
echo "$TRIAGE_OUT"
|
||||
|
||||
# Step B2: Auto-promote reinforced candidates + expire stale ones
|
||||
log "Step B2: auto-promote + expire"
|
||||
python3 "$APP_DIR/scripts/auto_promote_reinforced.py" \
|
||||
2>&1 || {
|
||||
log "WARN: auto-promote/expire failed (non-blocking)"
|
||||
}
|
||||
|
||||
# Step C: Daily project synthesis (keeps wiki/mirror pages fresh)
|
||||
log "Step C: project synthesis (daily)"
|
||||
python3 "$APP_DIR/scripts/synthesize_projects.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
2>&1 || {
|
||||
log "WARN: synthesis failed (non-blocking)"
|
||||
}
|
||||
|
||||
# Step D: Weekly lint pass (Sundays only — heavier, not needed daily)
|
||||
if [[ "$(date -u +%u)" == "7" ]]; then
|
||||
log "Step D: weekly lint pass"
|
||||
python3 "$APP_DIR/scripts/lint_knowledge_base.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
2>&1 || true
|
||||
fi
|
||||
|
||||
# Step E: Retrieval harness (daily)
|
||||
log "Step E: retrieval harness"
|
||||
HARNESS_OUT=$(python3 "$APP_DIR/scripts/retrieval_eval.py" \
|
||||
--json \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
2>&1) || {
|
||||
log "WARN: retrieval harness failed (non-blocking)"
|
||||
}
|
||||
echo "$HARNESS_OUT"
|
||||
|
||||
# Step F: Persist pipeline summary to project state
|
||||
log "Step F: pipeline summary"
|
||||
python3 -c "
|
||||
import json, urllib.request, re, sys
|
||||
|
||||
base = '$ATOCORE_URL'
|
||||
ts = '$TIMESTAMP'
|
||||
|
||||
def post_state(key, value):
|
||||
body = json.dumps({
|
||||
'project': 'atocore', 'category': 'status',
|
||||
'key': key, 'value': value, 'source': 'nightly pipeline',
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f'{base}/project/state', data=body,
|
||||
headers={'Content-Type': 'application/json'}, method='POST',
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
print(f'WARN: failed to persist {key}: {e}', file=sys.stderr)
|
||||
|
||||
# Parse harness JSON
|
||||
harness = {}
|
||||
try:
|
||||
harness = json.loads('''$HARNESS_OUT''')
|
||||
post_state('retrieval_harness_result', json.dumps({
|
||||
'passed': harness.get('passed', 0),
|
||||
'total': harness.get('total', 0),
|
||||
'failures': [f['name'] for f in harness.get('fixtures', []) if not f.get('ok')],
|
||||
'run_at': ts,
|
||||
}))
|
||||
p, t = harness.get('passed', '?'), harness.get('total', '?')
|
||||
print(f'Harness: {p}/{t}')
|
||||
except Exception:
|
||||
print('WARN: could not parse harness output')
|
||||
|
||||
# Parse triage counts from stdout
|
||||
triage_out = '''$TRIAGE_OUT'''
|
||||
promoted = len(re.findall(r'promoted', triage_out, re.IGNORECASE))
|
||||
rejected = len(re.findall(r'rejected', triage_out, re.IGNORECASE))
|
||||
needs_human = len(re.findall(r'needs.human', triage_out, re.IGNORECASE))
|
||||
|
||||
# Build summary
|
||||
summary = {
|
||||
'run_at': ts,
|
||||
'harness_passed': harness.get('passed', -1),
|
||||
'harness_total': harness.get('total', -1),
|
||||
'triage_promoted': promoted,
|
||||
'triage_rejected': rejected,
|
||||
'triage_needs_human': needs_human,
|
||||
}
|
||||
post_state('pipeline_last_run', ts)
|
||||
post_state('pipeline_summary', json.dumps(summary))
|
||||
print(f'Pipeline summary persisted: {json.dumps(summary)}')
|
||||
" 2>&1 || {
|
||||
log "WARN: pipeline summary persistence failed (non-blocking)"
|
||||
}
|
||||
|
||||
# Step F2: Emerging-concepts detector (Phase 6 C.1)
|
||||
log "Step F2: emerging-concepts detector"
|
||||
python3 "$APP_DIR/scripts/detect_emerging.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
2>&1 || {
|
||||
log "WARN: emerging detector failed (non-blocking)"
|
||||
}
|
||||
|
||||
# Step F3: Transient-to-durable extension (Phase 6 C.3)
|
||||
log "Step F3: transient-to-durable extension"
|
||||
curl -sSf -X POST "$ATOCORE_URL/admin/memory/extend-reinforced" \
|
||||
-H 'Content-Type: application/json' \
|
||||
2>&1 | tail -5 || {
|
||||
log "WARN: extend-reinforced failed (non-blocking)"
|
||||
}
|
||||
|
||||
# Step F4: Confidence decay on unreferenced cold memories (Phase 7D)
|
||||
# Daily: memories with reference_count=0 AND idle > 30 days → confidence × 0.97.
|
||||
# Below 0.3 → auto-supersede with audit. Reversible via reinforcement.
|
||||
log "Step F4: confidence decay"
|
||||
curl -sSf -X POST "$ATOCORE_URL/admin/memory/decay-run" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"idle_days_threshold": 30, "daily_decay_factor": 0.97, "supersede_confidence_floor": 0.30}' \
|
||||
2>&1 | tail -5 || {
|
||||
log "WARN: decay-run failed (non-blocking)"
|
||||
}
|
||||
|
||||
# Step B3: Memory dedup scan (Phase 7A)
|
||||
# Nightly at 0.90 (tight — only near-duplicates). Sundays run a deeper
|
||||
# pass at 0.85 to catch semantically-similar-but-differently-worded memories.
|
||||
if [[ "$(date -u +%u)" == "7" ]]; then
|
||||
DEDUP_THRESHOLD="0.85"
|
||||
DEDUP_BATCH="80"
|
||||
log "Step B3: memory dedup (Sunday deep pass, threshold $DEDUP_THRESHOLD)"
|
||||
else
|
||||
DEDUP_THRESHOLD="0.90"
|
||||
DEDUP_BATCH="50"
|
||||
log "Step B3: memory dedup (daily, threshold $DEDUP_THRESHOLD)"
|
||||
fi
|
||||
python3 "$APP_DIR/scripts/memory_dedup.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
--similarity-threshold "$DEDUP_THRESHOLD" \
|
||||
--max-batch "$DEDUP_BATCH" \
|
||||
2>&1 || {
|
||||
log "WARN: memory dedup failed (non-blocking)"
|
||||
}
|
||||
|
||||
# Step B4: Tag canonicalization (Phase 7C, weekly Sundays)
|
||||
# Autonomous: LLM proposes alias→canonical maps, auto-applies confidence >= 0.8.
|
||||
# Projects tokens are protected (skipped on both sides). Borderline proposals
|
||||
# land in /admin/tags/aliases for human review.
|
||||
if [[ "$(date -u +%u)" == "7" ]]; then
|
||||
log "Step B4: tag canonicalization (Sunday)"
|
||||
python3 "$APP_DIR/scripts/canonicalize_tags.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
2>&1 || {
|
||||
log "WARN: tag canonicalization failed (non-blocking)"
|
||||
}
|
||||
fi
|
||||
|
||||
# Step G: Integrity check (Phase 4 V1)
|
||||
log "Step G: integrity check"
|
||||
python3 "$APP_DIR/scripts/integrity_check.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
2>&1 || {
|
||||
log "WARN: integrity check failed (non-blocking)"
|
||||
}
|
||||
|
||||
# Step H: Pipeline-level alerts — detect conditions that warrant attention
|
||||
log "Step H: pipeline alerts"
|
||||
python3 -c "
|
||||
import json, os, sys, urllib.request
|
||||
sys.path.insert(0, '$APP_DIR/src')
|
||||
from atocore.observability.alerts import emit_alert
|
||||
|
||||
base = '$ATOCORE_URL'
|
||||
|
||||
def get_state(project='atocore'):
|
||||
try:
|
||||
req = urllib.request.Request(f'{base}/project/state/{project}')
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
return json.loads(resp.read()).get('entries', [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def get_dashboard():
|
||||
try:
|
||||
req = urllib.request.Request(f'{base}/admin/dashboard')
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
return json.loads(resp.read())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
state = {(e['category'], e['key']): e['value'] for e in get_state()}
|
||||
dash = get_dashboard()
|
||||
|
||||
# Harness regression check
|
||||
harness_raw = state.get(('status', 'retrieval_harness_result'))
|
||||
if harness_raw:
|
||||
try:
|
||||
h = json.loads(harness_raw)
|
||||
passed, total = h.get('passed', 0), h.get('total', 0)
|
||||
if total > 0:
|
||||
rate = passed / total
|
||||
if rate < 0.85:
|
||||
emit_alert('warning', 'Retrieval harness below 85%',
|
||||
f'Only {passed}/{total} fixtures passing ({rate:.0%}). Failures: {h.get(\"failures\", [])[:5]}',
|
||||
context={'pass_rate': rate})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Candidate queue pileup
|
||||
candidates = dash.get('memories', {}).get('candidates', 0)
|
||||
if candidates > 200:
|
||||
emit_alert('warning', 'Candidate queue not draining',
|
||||
f'{candidates} candidates pending. Auto-triage may be stuck or rate-limited.',
|
||||
context={'candidates': candidates})
|
||||
|
||||
print('pipeline alerts check complete')
|
||||
" 2>&1 || true
|
||||
|
||||
log "=== AtoCore batch extraction + triage complete ==="
|
||||
129
deploy/dalidou/cron-backup.sh
Executable file
129
deploy/dalidou/cron-backup.sh
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deploy/dalidou/cron-backup.sh
|
||||
# ------------------------------
|
||||
# Daily backup + retention cleanup via the AtoCore API.
|
||||
#
|
||||
# Intended to run from cron on Dalidou:
|
||||
#
|
||||
# # Daily at 03:00 UTC
|
||||
# 0 3 * * * /srv/storage/atocore/app/deploy/dalidou/cron-backup.sh >> /var/log/atocore-backup.log 2>&1
|
||||
#
|
||||
# What it does:
|
||||
# 1. Creates a runtime backup (db + registry, no chroma by default)
|
||||
# 2. Runs retention cleanup with --confirm to delete old snapshots
|
||||
# 3. Logs results to stdout (captured by cron into the log file)
|
||||
#
|
||||
# Fail-open: exits 0 even on API errors so cron doesn't send noise
|
||||
# emails. Check /var/log/atocore-backup.log for diagnostics.
|
||||
#
|
||||
# Environment variables:
|
||||
# ATOCORE_URL default http://127.0.0.1:8100
|
||||
# ATOCORE_BACKUP_CHROMA default false (set to "true" for cold chroma copy)
|
||||
# ATOCORE_BACKUP_DIR default /srv/storage/atocore/backups
|
||||
# ATOCORE_BACKUP_RSYNC optional rsync destination for off-host copies
|
||||
# (e.g. papa@laptop:/home/papa/atocore-backups/)
|
||||
# When set, the local snapshots tree is rsynced to
|
||||
# the destination after cleanup. Unset = skip.
|
||||
# SSH key auth must already be configured from this
|
||||
# host to the destination.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ATOCORE_URL="${ATOCORE_URL:-http://127.0.0.1:8100}"
|
||||
INCLUDE_CHROMA="${ATOCORE_BACKUP_CHROMA:-false}"
|
||||
BACKUP_DIR="${ATOCORE_BACKUP_DIR:-/srv/storage/atocore/backups}"
|
||||
RSYNC_TARGET="${ATOCORE_BACKUP_RSYNC:-}"
|
||||
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
log() { printf '[%s] %s\n' "$TIMESTAMP" "$*"; }
|
||||
|
||||
log "=== AtoCore daily backup starting ==="
|
||||
|
||||
# Step 1: Create backup
|
||||
log "Step 1: creating backup (chroma=$INCLUDE_CHROMA)"
|
||||
BACKUP_RESULT=$(curl -sf -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"include_chroma\": $INCLUDE_CHROMA}" \
|
||||
"$ATOCORE_URL/admin/backup" 2>&1) || {
|
||||
log "ERROR: backup creation failed: $BACKUP_RESULT"
|
||||
exit 0
|
||||
}
|
||||
log "Backup created: $BACKUP_RESULT"
|
||||
|
||||
# Step 2: Retention cleanup (confirm=true to actually delete)
|
||||
log "Step 2: running retention cleanup"
|
||||
CLEANUP_RESULT=$(curl -sf -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"confirm": true}' \
|
||||
"$ATOCORE_URL/admin/backup/cleanup" 2>&1) || {
|
||||
log "ERROR: cleanup failed: $CLEANUP_RESULT"
|
||||
exit 0
|
||||
}
|
||||
log "Cleanup result: $CLEANUP_RESULT"
|
||||
|
||||
# Step 3: Off-host rsync (optional). Fail-open: log but don't abort
|
||||
# the cron so a laptop being offline at 03:00 UTC never turns the
|
||||
# local backup path red.
|
||||
if [[ -n "$RSYNC_TARGET" ]]; then
|
||||
log "Step 3: rsyncing snapshots to $RSYNC_TARGET"
|
||||
if [[ ! -d "$BACKUP_DIR/snapshots" ]]; then
|
||||
log "WARN: $BACKUP_DIR/snapshots does not exist, skipping rsync"
|
||||
else
|
||||
RSYNC_OUTPUT=$(rsync -a --delete \
|
||||
-e "ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new" \
|
||||
"$BACKUP_DIR/snapshots/" "$RSYNC_TARGET" 2>&1) && {
|
||||
log "Rsync complete"
|
||||
} || {
|
||||
log "WARN: rsync to $RSYNC_TARGET failed (offline or auth?): $RSYNC_OUTPUT"
|
||||
}
|
||||
fi
|
||||
else
|
||||
log "Step 3: ATOCORE_BACKUP_RSYNC not set, skipping off-host copy"
|
||||
fi
|
||||
|
||||
# Step 3a: Pull OpenClaw state from clawdbot (one-way import of
|
||||
# SOUL.md, USER.md, MODEL-ROUTING.md, MEMORY.md, recent memory/*.md).
|
||||
# Loose coupling: OpenClaw's internals don't need to change.
|
||||
# Fail-open: importer failure never blocks the pipeline.
|
||||
log "Step 3a: pull OpenClaw state"
|
||||
OPENCLAW_IMPORT="${ATOCORE_OPENCLAW_IMPORT:-true}"
|
||||
if [[ "$OPENCLAW_IMPORT" == "true" ]]; then
|
||||
python3 "$SCRIPT_DIR/../../scripts/import_openclaw_state.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
2>&1 | while IFS= read -r line; do log " $line"; done || {
|
||||
log " WARN: OpenClaw import failed (non-blocking)"
|
||||
}
|
||||
else
|
||||
log " skipped (ATOCORE_OPENCLAW_IMPORT != true)"
|
||||
fi
|
||||
|
||||
# Step 3b: Auto-refresh vault sources so new PKM files flow in
|
||||
# automatically. Fail-open: never blocks the rest of the pipeline.
|
||||
log "Step 3b: auto-refresh vault sources"
|
||||
REFRESH_RESULT=$(curl -sf -X POST --max-time 600 \
|
||||
"$ATOCORE_URL/ingest/sources" 2>&1) && {
|
||||
log "Sources refresh complete"
|
||||
} || {
|
||||
log "WARN: sources refresh failed (non-blocking): $REFRESH_RESULT"
|
||||
}
|
||||
|
||||
# Step 4: Batch LLM extraction on recent interactions (optional).
|
||||
# Runs HOST-SIDE because claude CLI is on the host, not inside the
|
||||
# Docker container. The script fetches interactions from the API,
|
||||
# runs claude -p locally, and POSTs candidates back.
|
||||
# Fail-open: extraction failure never blocks backup.
|
||||
EXTRACT="${ATOCORE_EXTRACT_BATCH:-true}"
|
||||
if [[ "$EXTRACT" == "true" ]]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
log "Step 4: running host-side batch LLM extraction"
|
||||
bash "$SCRIPT_DIR/batch-extract.sh" 2>&1 && {
|
||||
log "Extraction complete"
|
||||
} || {
|
||||
log "WARN: batch extraction failed (this is non-blocking)"
|
||||
}
|
||||
else
|
||||
log "Step 4: ATOCORE_EXTRACT_BATCH not set to true, skipping extraction"
|
||||
fi
|
||||
|
||||
log "=== AtoCore daily backup complete ==="
|
||||
110
deploy/dalidou/dedup-watcher.sh
Normal file
110
deploy/dalidou/dedup-watcher.sh
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deploy/dalidou/dedup-watcher.sh
|
||||
# -------------------------------
|
||||
# Host-side watcher for on-demand memory dedup scans (Phase 7A).
|
||||
#
|
||||
# The /admin/triage page has a "🔗 Scan for duplicates" button that POSTs
|
||||
# to /admin/memory/dedup-scan with {project, similarity_threshold, max_batch}.
|
||||
# The container writes this to project_state (atocore/config/dedup_requested_at).
|
||||
#
|
||||
# This script runs on the Dalidou HOST (where claude CLI lives), polls
|
||||
# for the flag, and runs memory_dedup.py when seen.
|
||||
#
|
||||
# Installed via cron every 2 minutes:
|
||||
# */2 * * * * /srv/storage/atocore/app/deploy/dalidou/dedup-watcher.sh \
|
||||
# >> /home/papa/atocore-logs/dedup-watcher.log 2>&1
|
||||
#
|
||||
# Mirrors deploy/dalidou/graduation-watcher.sh exactly.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ATOCORE_URL="${ATOCORE_URL:-http://127.0.0.1:8100}"
|
||||
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
LOCK_FILE="/tmp/atocore-dedup.lock"
|
||||
LOG_DIR="/home/papa/atocore-logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
log() { printf '[%s] %s\n' "$TS" "$*"; }
|
||||
|
||||
# Fetch the flag via API
|
||||
STATE_JSON=$(curl -sSf --max-time 5 "$ATOCORE_URL/project/state/atocore" 2>/dev/null || echo "{}")
|
||||
REQUESTED=$(echo "$STATE_JSON" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
for e in d.get('entries', d.get('state', [])):
|
||||
if e.get('category') == 'config' and e.get('key') == 'dedup_requested_at':
|
||||
print(e.get('value', ''))
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$REQUESTED" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PROJECT=$(echo "$REQUESTED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('project',''))" 2>/dev/null || echo "")
|
||||
THRESHOLD=$(echo "$REQUESTED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('similarity_threshold',0.88))" 2>/dev/null || echo "0.88")
|
||||
MAX_BATCH=$(echo "$REQUESTED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('max_batch',50))" 2>/dev/null || echo "50")
|
||||
|
||||
# Acquire lock
|
||||
exec 9>"$LOCK_FILE" || exit 0
|
||||
if ! flock -n 9; then
|
||||
log "dedup already running, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Mark running
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"dedup_running\",\"value\":\"1\",\"source\":\"dedup watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"dedup_last_started_at\",\"value\":\"$TS\",\"source\":\"dedup watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
LOG_FILE="$LOG_DIR/dedup-ondemand-$(date -u +%Y%m%d-%H%M%S).log"
|
||||
log "Starting dedup (project='$PROJECT' threshold=$THRESHOLD max_batch=$MAX_BATCH, log: $LOG_FILE)"
|
||||
|
||||
# Clear the flag BEFORE running so duplicate clicks queue at most one
|
||||
curl -sSf -X DELETE "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"config\",\"key\":\"dedup_requested_at\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
cd "$APP_DIR"
|
||||
export PYTHONPATH="$APP_DIR/src:${PYTHONPATH:-}"
|
||||
ARGS=(--base-url "$ATOCORE_URL" --similarity-threshold "$THRESHOLD" --max-batch "$MAX_BATCH")
|
||||
if [[ -n "$PROJECT" ]]; then
|
||||
ARGS+=(--project "$PROJECT")
|
||||
fi
|
||||
|
||||
if python3 scripts/memory_dedup.py "${ARGS[@]}" >> "$LOG_FILE" 2>&1; then
|
||||
RESULT=$(grep "^summary:" "$LOG_FILE" | tail -1 || tail -1 "$LOG_FILE")
|
||||
RESULT="${RESULT:-completed}"
|
||||
log "dedup finished: $RESULT"
|
||||
else
|
||||
RESULT="ERROR — see $LOG_FILE"
|
||||
log "dedup FAILED"
|
||||
fi
|
||||
|
||||
FINISH_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"dedup_running\",\"value\":\"0\",\"source\":\"dedup watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"dedup_last_finished_at\",\"value\":\"$FINISH_TS\",\"source\":\"dedup watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
SAFE_RESULT=$(printf '%s' "$RESULT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read())[1:-1])")
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"dedup_last_result\",\"value\":\"$SAFE_RESULT\",\"source\":\"dedup watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
349
deploy/dalidou/deploy.sh
Normal file
349
deploy/dalidou/deploy.sh
Normal file
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deploy/dalidou/deploy.sh
|
||||
# -------------------------
|
||||
# One-shot deploy script for updating the running AtoCore container
|
||||
# on Dalidou from the current Gitea main branch.
|
||||
#
|
||||
# The script is idempotent and safe to re-run. It handles both the
|
||||
# first-time deploy (where /srv/storage/atocore/app may not yet be
|
||||
# a git checkout) and the ongoing update case (where it is).
|
||||
#
|
||||
# Usage
|
||||
# -----
|
||||
#
|
||||
# # Normal update from main (most common)
|
||||
# bash deploy/dalidou/deploy.sh
|
||||
#
|
||||
# # Deploy a specific branch or tag
|
||||
# ATOCORE_BRANCH=codex/some-feature bash deploy/dalidou/deploy.sh
|
||||
#
|
||||
# # Dry-run: show what would happen without touching anything
|
||||
# ATOCORE_DEPLOY_DRY_RUN=1 bash deploy/dalidou/deploy.sh
|
||||
#
|
||||
# Environment variables
|
||||
# ---------------------
|
||||
#
|
||||
# ATOCORE_APP_DIR default /srv/storage/atocore/app
|
||||
# ATOCORE_GIT_REMOTE default http://127.0.0.1:3000/Antoine/ATOCore.git
|
||||
# This is the local Dalidou gitea, reached
|
||||
# via loopback. Override only when running
|
||||
# the deploy from a remote host. The default
|
||||
# is loopback (not the hostname "dalidou")
|
||||
# because the hostname doesn't reliably
|
||||
# resolve on the host itself — Dalidou
|
||||
# Claude's first deploy had to work around
|
||||
# exactly this.
|
||||
# ATOCORE_BRANCH default main
|
||||
# ATOCORE_DEPLOY_DRY_RUN if set to 1, report only, no mutations
|
||||
# ATOCORE_HEALTH_URL default http://127.0.0.1:8100/health
|
||||
#
|
||||
# Safety rails
|
||||
# ------------
|
||||
#
|
||||
# - If the app dir exists but is NOT a git repo, the script renames
|
||||
# it to <dir>.pre-git-<timestamp> before re-cloning, so you never
|
||||
# lose the pre-existing snapshot to a git clobber.
|
||||
# - If the health check fails after restart, the script exits
|
||||
# non-zero and prints the container logs tail for diagnosis.
|
||||
# - Dry-run mode is the default recommendation for the first deploy
|
||||
# on a new environment: it shows the planned git operations and
|
||||
# the compose command without actually running them.
|
||||
#
|
||||
# What this script does NOT do
|
||||
# ----------------------------
|
||||
#
|
||||
# - Does not manage secrets / .env files. The caller is responsible
|
||||
# for placing deploy/dalidou/.env before running.
|
||||
# - Does not run a backup before deploying. Run the backup endpoint
|
||||
# first if you want a pre-deploy snapshot.
|
||||
# - Does not roll back on health-check failure. If deploy fails,
|
||||
# the previous container is already stopped; you need to redeploy
|
||||
# a known-good commit to recover.
|
||||
# - Does not touch the database. The Phase 9 schema migrations in
|
||||
# src/atocore/models/database.py::_apply_migrations are idempotent
|
||||
# ALTER TABLE ADD COLUMN calls that run at service startup via the
|
||||
# lifespan handler. Stale pre-Phase-9 schema is upgraded in place.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${ATOCORE_APP_DIR:-/srv/storage/atocore/app}"
|
||||
GIT_REMOTE="${ATOCORE_GIT_REMOTE:-http://127.0.0.1:3000/Antoine/ATOCore.git}"
|
||||
BRANCH="${ATOCORE_BRANCH:-main}"
|
||||
HEALTH_URL="${ATOCORE_HEALTH_URL:-http://127.0.0.1:8100/health}"
|
||||
DRY_RUN="${ATOCORE_DEPLOY_DRY_RUN:-0}"
|
||||
COMPOSE_DIR="$APP_DIR/deploy/dalidou"
|
||||
|
||||
log() { printf '==> %s\n' "$*"; }
|
||||
run() {
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
printf ' [dry-run] %s\n' "$*"
|
||||
else
|
||||
eval "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
log "AtoCore deploy starting"
|
||||
log " app dir: $APP_DIR"
|
||||
log " git remote: $GIT_REMOTE"
|
||||
log " branch: $BRANCH"
|
||||
log " health url: $HEALTH_URL"
|
||||
log " dry run: $DRY_RUN"
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Step 0: pre-flight permission check
|
||||
# ---------------------------------------------------------------------
|
||||
#
|
||||
# If $APP_DIR exists but the current user cannot write to it (because
|
||||
# a previous manual deploy left it root-owned, for example), the git
|
||||
# fetch / reset in step 1 will fail with cryptic errors. Detect this
|
||||
# up front and give the operator a clean remediation command instead
|
||||
# of letting git produce half-state on partial failure. This was the
|
||||
# exact workaround the 2026-04-08 Dalidou redeploy needed — pre-
|
||||
# existing root ownership from the pre-phase9 manual schema fix.
|
||||
|
||||
if [ -d "$APP_DIR" ] && [ "$DRY_RUN" != "1" ]; then
|
||||
if [ ! -w "$APP_DIR" ] || [ ! -r "$APP_DIR/.git" ] 2>/dev/null; then
|
||||
log "WARNING: app dir exists but may not be writable by current user"
|
||||
fi
|
||||
current_owner="$(stat -c '%U:%G' "$APP_DIR" 2>/dev/null || echo unknown)"
|
||||
current_user="$(id -un 2>/dev/null || echo unknown)"
|
||||
current_uid_gid="$(id -u 2>/dev/null):$(id -g 2>/dev/null)"
|
||||
log "Step 0: permission check"
|
||||
log " app dir owner: $current_owner"
|
||||
log " current user: $current_user ($current_uid_gid)"
|
||||
# Try to write a tiny marker file. If it fails, surface a clean
|
||||
# remediation message and exit before git produces confusing
|
||||
# half-state.
|
||||
marker="$APP_DIR/.deploy-permission-check"
|
||||
if ! ( : > "$marker" ) 2>/dev/null; then
|
||||
log "FATAL: cannot write to $APP_DIR as $current_user"
|
||||
log ""
|
||||
log "The app dir is owned by $current_owner and the current user"
|
||||
log "doesn't have write permission. This usually happens after a"
|
||||
log "manual workaround deploy that ran as root."
|
||||
log ""
|
||||
log "Remediation (pick the one that matches your setup):"
|
||||
log ""
|
||||
log " # If you have passwordless sudo and gitea runs as UID 1000:"
|
||||
log " sudo chown -R 1000:1000 $APP_DIR"
|
||||
log ""
|
||||
log " # If you're running deploy.sh itself as root:"
|
||||
log " sudo bash $0"
|
||||
log ""
|
||||
log " # If neither works, do it via a throwaway container:"
|
||||
log " docker run --rm -v $APP_DIR:/app alpine \\"
|
||||
log " chown -R 1000:1000 /app"
|
||||
log ""
|
||||
log "Then re-run deploy.sh."
|
||||
exit 5
|
||||
fi
|
||||
rm -f "$marker" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Step 1: make sure $APP_DIR is a proper git checkout of the branch
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
if [ -d "$APP_DIR/.git" ]; then
|
||||
log "Step 1: app dir is already a git checkout; fetching latest"
|
||||
run "cd '$APP_DIR' && git fetch origin '$BRANCH'"
|
||||
run "cd '$APP_DIR' && git reset --hard 'origin/$BRANCH'"
|
||||
else
|
||||
log "Step 1: app dir is NOT a git checkout; converting"
|
||||
if [ -d "$APP_DIR" ]; then
|
||||
BACKUP="${APP_DIR}.pre-git-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
log " backing up existing snapshot to $BACKUP"
|
||||
run "mv '$APP_DIR' '$BACKUP'"
|
||||
fi
|
||||
log " cloning $GIT_REMOTE -> $APP_DIR (branch: $BRANCH)"
|
||||
run "git clone --branch '$BRANCH' '$GIT_REMOTE' '$APP_DIR'"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Step 1.5: self-update re-exec guard
|
||||
# ---------------------------------------------------------------------
|
||||
#
|
||||
# When deploy.sh itself changes in the commit we just pulled, the bash
|
||||
# process running this script is still executing the OLD deploy.sh
|
||||
# from memory — git reset --hard updated the file on disk but our
|
||||
# in-memory instructions are stale. That's exactly how the first
|
||||
# 2026-04-09 Dalidou deploy silently wrote "unknown" build_sha: old
|
||||
# Step 2 logic ran against fresh source. Detect the mismatch and
|
||||
# re-exec into the fresh copy so every post-update run exercises the
|
||||
# new script.
|
||||
#
|
||||
# Guard rails:
|
||||
# - Only runs when $APP_DIR exists, holds a git checkout, and a
|
||||
# deploy.sh exists there (i.e. after Step 1 succeeded).
|
||||
# - Uses a sentinel env var ATOCORE_DEPLOY_REEXECED=1 to make sure
|
||||
# we only re-exec once, never recurse.
|
||||
# - Skipped in dry-run mode (no mutation).
|
||||
# - Skipped if $0 isn't a readable file (bash -c pipe inputs, etc.).
|
||||
|
||||
if [ "$DRY_RUN" != "1" ] \
|
||||
&& [ -z "${ATOCORE_DEPLOY_REEXECED:-}" ] \
|
||||
&& [ -r "$0" ] \
|
||||
&& [ -f "$APP_DIR/deploy/dalidou/deploy.sh" ]; then
|
||||
ON_DISK_HASH="$(sha1sum "$APP_DIR/deploy/dalidou/deploy.sh" 2>/dev/null | awk '{print $1}')"
|
||||
RUNNING_HASH="$(sha1sum "$0" 2>/dev/null | awk '{print $1}')"
|
||||
if [ -n "$ON_DISK_HASH" ] \
|
||||
&& [ -n "$RUNNING_HASH" ] \
|
||||
&& [ "$ON_DISK_HASH" != "$RUNNING_HASH" ]; then
|
||||
log "Step 1.5: deploy.sh changed in the pulled commit; re-exec'ing"
|
||||
log " running script hash: $RUNNING_HASH"
|
||||
log " on-disk script hash: $ON_DISK_HASH"
|
||||
log " re-exec -> $APP_DIR/deploy/dalidou/deploy.sh"
|
||||
export ATOCORE_DEPLOY_REEXECED=1
|
||||
exec bash "$APP_DIR/deploy/dalidou/deploy.sh" "$@"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Step 2: capture build provenance to pass to the container
|
||||
# ---------------------------------------------------------------------
|
||||
#
|
||||
# We compute the full SHA, the short SHA, the UTC build timestamp,
|
||||
# and the source branch. These get exported as env vars before
|
||||
# `docker compose up -d --build` so the running container can read
|
||||
# them at startup and report them via /health. The post-deploy
|
||||
# verification step (Step 6) reads /health and compares the
|
||||
# reported SHA against this value to detect any silent drift.
|
||||
|
||||
log "Step 2: capturing build provenance"
|
||||
if [ "$DRY_RUN" != "1" ] && [ -d "$APP_DIR/.git" ]; then
|
||||
DEPLOYING_SHA_FULL="$(cd "$APP_DIR" && git rev-parse HEAD)"
|
||||
DEPLOYING_SHA="$(echo "$DEPLOYING_SHA_FULL" | cut -c1-7)"
|
||||
DEPLOYING_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
DEPLOYING_BRANCH="$BRANCH"
|
||||
log " commit: $DEPLOYING_SHA ($DEPLOYING_SHA_FULL)"
|
||||
log " built at: $DEPLOYING_TIME"
|
||||
log " branch: $DEPLOYING_BRANCH"
|
||||
( cd "$APP_DIR" && git log --oneline -1 ) | sed 's/^/ /'
|
||||
export ATOCORE_BUILD_SHA="$DEPLOYING_SHA_FULL"
|
||||
export ATOCORE_BUILD_TIME="$DEPLOYING_TIME"
|
||||
export ATOCORE_BUILD_BRANCH="$DEPLOYING_BRANCH"
|
||||
else
|
||||
log " [dry-run] would read git log from $APP_DIR"
|
||||
DEPLOYING_SHA="dry-run"
|
||||
DEPLOYING_SHA_FULL="dry-run"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Step 3: preserve the .env file (it's not in git)
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
ENV_FILE="$COMPOSE_DIR/.env"
|
||||
if [ "$DRY_RUN" != "1" ] && [ ! -f "$ENV_FILE" ]; then
|
||||
log "Step 3: WARNING — $ENV_FILE does not exist"
|
||||
log " the compose workflow needs this file to map mount points"
|
||||
log " copy deploy/dalidou/.env.example to $ENV_FILE and edit it"
|
||||
log " before re-running this script"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Step 4: rebuild and restart the container
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
log "Step 4: rebuilding and restarting the atocore container"
|
||||
run "cd '$COMPOSE_DIR' && docker compose up -d --build"
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
log "dry-run complete — no mutations performed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Step 5: wait for the service to come up and pass the health check
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
log "Step 5: waiting for /health to respond"
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl -fsS "$HEALTH_URL" > /tmp/atocore-health.json 2>/dev/null; then
|
||||
log " service is responding"
|
||||
break
|
||||
fi
|
||||
log " not ready yet ($i/10); waiting 3s"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if ! curl -fsS "$HEALTH_URL" > /tmp/atocore-health.json 2>/dev/null; then
|
||||
log "FATAL: service did not come up within 30 seconds"
|
||||
log " container logs (last 50 lines):"
|
||||
cd "$COMPOSE_DIR" && docker compose logs --tail=50 atocore || true
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Step 6: verify the deployed build matches what we just shipped
|
||||
# ---------------------------------------------------------------------
|
||||
#
|
||||
# Two layers of comparison:
|
||||
#
|
||||
# - code_version: matches src/atocore/__init__.py::__version__.
|
||||
# Coarse: any commit between version bumps reports the same value.
|
||||
# - build_sha: full git SHA the container was built from. Set as
|
||||
# an env var by Step 2 above and read by /health from
|
||||
# ATOCORE_BUILD_SHA. This is the precise drift signal — if the
|
||||
# live build_sha doesn't match $DEPLOYING_SHA_FULL, the build
|
||||
# didn't pick up the new source.
|
||||
|
||||
log "Step 6: verifying deployed build"
|
||||
log " /health response:"
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq . < /tmp/atocore-health.json | sed 's/^/ /'
|
||||
REPORTED_VERSION="$(jq -r '.code_version // .version' < /tmp/atocore-health.json)"
|
||||
REPORTED_SHA="$(jq -r '.build_sha // "unknown"' < /tmp/atocore-health.json)"
|
||||
REPORTED_BUILD_TIME="$(jq -r '.build_time // "unknown"' < /tmp/atocore-health.json)"
|
||||
else
|
||||
cat /tmp/atocore-health.json | sed 's/^/ /'
|
||||
echo
|
||||
REPORTED_VERSION="$(grep -o '"code_version":"[^"]*"' /tmp/atocore-health.json | head -1 | cut -d'"' -f4)"
|
||||
if [ -z "$REPORTED_VERSION" ]; then
|
||||
REPORTED_VERSION="$(grep -o '"version":"[^"]*"' /tmp/atocore-health.json | head -1 | cut -d'"' -f4)"
|
||||
fi
|
||||
REPORTED_SHA="$(grep -o '"build_sha":"[^"]*"' /tmp/atocore-health.json | head -1 | cut -d'"' -f4)"
|
||||
REPORTED_SHA="${REPORTED_SHA:-unknown}"
|
||||
REPORTED_BUILD_TIME="$(grep -o '"build_time":"[^"]*"' /tmp/atocore-health.json | head -1 | cut -d'"' -f4)"
|
||||
REPORTED_BUILD_TIME="${REPORTED_BUILD_TIME:-unknown}"
|
||||
fi
|
||||
|
||||
EXPECTED_VERSION="$(grep -oE "__version__ = \"[^\"]+\"" "$APP_DIR/src/atocore/__init__.py" | head -1 | cut -d'"' -f2)"
|
||||
|
||||
log " Layer 1 — coarse version:"
|
||||
log " expected code_version: $EXPECTED_VERSION (from src/atocore/__init__.py)"
|
||||
log " reported code_version: $REPORTED_VERSION (from live /health)"
|
||||
|
||||
if [ "$REPORTED_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
log "FATAL: code_version mismatch"
|
||||
log " the container may not have picked up the new image"
|
||||
log " try: docker compose down && docker compose up -d --build"
|
||||
exit 4
|
||||
fi
|
||||
|
||||
log " Layer 2 — precise build SHA:"
|
||||
log " expected build_sha: $DEPLOYING_SHA_FULL (from this deploy.sh run)"
|
||||
log " reported build_sha: $REPORTED_SHA (from live /health)"
|
||||
log " reported build_time: $REPORTED_BUILD_TIME"
|
||||
|
||||
if [ "$REPORTED_SHA" != "$DEPLOYING_SHA_FULL" ]; then
|
||||
log "FATAL: build_sha mismatch"
|
||||
log " the live container is reporting a different commit than"
|
||||
log " the one this deploy.sh run just shipped. Possible causes:"
|
||||
log " - the container is using a cached image instead of the"
|
||||
log " freshly-built one (try: docker compose build --no-cache)"
|
||||
log " - the env vars didn't propagate (check that"
|
||||
log " deploy/dalidou/docker-compose.yml has the environment"
|
||||
log " section with ATOCORE_BUILD_SHA)"
|
||||
log " - another process restarted the container between the"
|
||||
log " build and the health check"
|
||||
exit 6
|
||||
fi
|
||||
|
||||
log "Deploy complete."
|
||||
log " commit: $DEPLOYING_SHA ($DEPLOYING_SHA_FULL)"
|
||||
log " code_version: $REPORTED_VERSION"
|
||||
log " build_sha: $REPORTED_SHA"
|
||||
log " build_time: $REPORTED_BUILD_TIME"
|
||||
log " health: ok"
|
||||
@@ -9,6 +9,15 @@ services:
|
||||
- "${ATOCORE_PORT:-8100}:8100"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Build provenance — set by deploy/dalidou/deploy.sh on each
|
||||
# rebuild so /health can report exactly which commit is live.
|
||||
# Defaults to 'unknown' for direct `docker compose up` runs that
|
||||
# bypass deploy.sh; in that case the operator should run
|
||||
# deploy.sh instead so the deployed SHA is recorded.
|
||||
ATOCORE_BUILD_SHA: "${ATOCORE_BUILD_SHA:-unknown}"
|
||||
ATOCORE_BUILD_TIME: "${ATOCORE_BUILD_TIME:-unknown}"
|
||||
ATOCORE_BUILD_BRANCH: "${ATOCORE_BUILD_BRANCH:-unknown}"
|
||||
volumes:
|
||||
- ${ATOCORE_DB_DIR}:${ATOCORE_DB_DIR}
|
||||
- ${ATOCORE_CHROMA_DIR}:${ATOCORE_CHROMA_DIR}
|
||||
|
||||
117
deploy/dalidou/graduation-watcher.sh
Normal file
117
deploy/dalidou/graduation-watcher.sh
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deploy/dalidou/graduation-watcher.sh
|
||||
# ------------------------------------
|
||||
# Host-side watcher for on-demand memory→entity graduation from the web UI.
|
||||
#
|
||||
# The /admin/triage page has a "🎓 Graduate memories" button that POSTs
|
||||
# to /admin/graduation/request with {project, limit}. The container
|
||||
# writes this to project_state (atocore/config/graduation_requested_at).
|
||||
#
|
||||
# This script runs on the Dalidou HOST (where claude CLI lives), polls
|
||||
# for the flag, and runs graduate_memories.py when seen.
|
||||
#
|
||||
# Installed via cron every 2 minutes:
|
||||
# */2 * * * * /srv/storage/atocore/app/deploy/dalidou/graduation-watcher.sh \
|
||||
# >> /home/papa/atocore-logs/graduation-watcher.log 2>&1
|
||||
#
|
||||
# Safety:
|
||||
# - Lock file prevents concurrent runs
|
||||
# - Flag cleared before processing so duplicate clicks queue at most one re-run
|
||||
# - Fail-open: any error logs but doesn't break the host
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ATOCORE_URL="${ATOCORE_URL:-http://127.0.0.1:8100}"
|
||||
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
LOCK_FILE="/tmp/atocore-graduation.lock"
|
||||
LOG_DIR="/home/papa/atocore-logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
log() { printf '[%s] %s\n' "$TS" "$*"; }
|
||||
|
||||
# Fetch the flag via API
|
||||
STATE_JSON=$(curl -sSf --max-time 5 "$ATOCORE_URL/project/state/atocore" 2>/dev/null || echo "{}")
|
||||
REQUESTED=$(echo "$STATE_JSON" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
for e in d.get('entries', d.get('state', [])):
|
||||
if e.get('category') == 'config' and e.get('key') == 'graduation_requested_at':
|
||||
print(e.get('value', ''))
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$REQUESTED" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse JSON: {project, limit, requested_at}
|
||||
PROJECT=$(echo "$REQUESTED" | python3 -c "import sys,json; d=json.load(sys.stdin) if '{' in sys.stdin.buffer.peek().decode(errors='ignore') else None; print((d or {}).get('project',''))" 2>/dev/null || echo "")
|
||||
# Fallback: python inline above can be flaky; just re-parse
|
||||
PROJECT=$(echo "$REQUESTED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('project',''))" 2>/dev/null || echo "")
|
||||
LIMIT=$(echo "$REQUESTED" | python3 -c "import sys,json; print(json.loads(sys.stdin.read() or '{}').get('limit',30))" 2>/dev/null || echo "30")
|
||||
|
||||
# Acquire lock
|
||||
exec 9>"$LOCK_FILE" || exit 0
|
||||
if ! flock -n 9; then
|
||||
log "graduation already running, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Mark running
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"graduation_running\",\"value\":\"1\",\"source\":\"host watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"graduation_last_started_at\",\"value\":\"$TS\",\"source\":\"host watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
LOG_FILE="$LOG_DIR/graduation-ondemand-$(date -u +%Y%m%d-%H%M%S).log"
|
||||
log "Starting graduation (project='$PROJECT' limit=$LIMIT, log: $LOG_FILE)"
|
||||
|
||||
# Clear the flag BEFORE running so duplicate clicks queue at most one
|
||||
curl -sSf -X DELETE "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"config\",\"key\":\"graduation_requested_at\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
# Build script args
|
||||
cd "$APP_DIR"
|
||||
export PYTHONPATH="$APP_DIR/src:${PYTHONPATH:-}"
|
||||
ARGS=(--base-url "$ATOCORE_URL" --limit "$LIMIT")
|
||||
if [[ -n "$PROJECT" ]]; then
|
||||
ARGS+=(--project "$PROJECT")
|
||||
fi
|
||||
|
||||
if python3 scripts/graduate_memories.py "${ARGS[@]}" >> "$LOG_FILE" 2>&1; then
|
||||
RESULT=$(tail -3 "$LOG_FILE" | grep "^total:" | tail -1 || tail -1 "$LOG_FILE")
|
||||
RESULT="${RESULT:-completed}"
|
||||
log "graduation finished: $RESULT"
|
||||
else
|
||||
RESULT="ERROR — see $LOG_FILE"
|
||||
log "graduation FAILED"
|
||||
fi
|
||||
|
||||
FINISH_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
# Mark done
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"graduation_running\",\"value\":\"0\",\"source\":\"host watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"graduation_last_finished_at\",\"value\":\"$FINISH_TS\",\"source\":\"host watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
SAFE_RESULT=$(printf '%s' "$RESULT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read())[1:-1])")
|
||||
curl -sSf -X POST "$ATOCORE_URL/project/state" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"project\":\"atocore\",\"category\":\"status\",\"key\":\"graduation_last_result\",\"value\":\"$SAFE_RESULT\",\"source\":\"host watcher\"}" \
|
||||
>/dev/null 2>&1 || true
|
||||
64
deploy/dalidou/hourly-extract.sh
Normal file
64
deploy/dalidou/hourly-extract.sh
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deploy/dalidou/hourly-extract.sh
|
||||
# ---------------------------------
|
||||
# Lightweight hourly extraction + triage so autonomous capture stays
|
||||
# current (not a 24h-latency nightly-only affair).
|
||||
#
|
||||
# Does ONLY:
|
||||
# Step A: LLM extraction over recent interactions (last 2h window)
|
||||
# Step B: 3-tier auto-triage on the resulting candidates
|
||||
#
|
||||
# Skips the heavy nightly stuff (backup, rsync, OpenClaw import,
|
||||
# synthesis, harness, integrity check, emerging detector). Those stay
|
||||
# in cron-backup.sh at 03:00 UTC.
|
||||
#
|
||||
# Runs every hour via cron:
|
||||
# 0 * * * * /srv/storage/atocore/app/deploy/dalidou/hourly-extract.sh \
|
||||
# >> /home/papa/atocore-logs/hourly-extract.log 2>&1
|
||||
#
|
||||
# Lock file prevents overlap if a previous run is still going (which
|
||||
# can happen if claude CLI rate-limits and retries).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ATOCORE_URL="${ATOCORE_URL:-http://127.0.0.1:8100}"
|
||||
# 50 recent interactions is enough for an hour — typical usage is under 20/h.
|
||||
LIMIT="${ATOCORE_HOURLY_EXTRACT_LIMIT:-50}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
LOCK_FILE="/tmp/atocore-hourly-extract.lock"
|
||||
|
||||
log() { printf '[%s] %s\n' "$TIMESTAMP" "$*"; }
|
||||
|
||||
# Acquire lock (non-blocking)
|
||||
exec 9>"$LOCK_FILE" || exit 0
|
||||
if ! flock -n 9; then
|
||||
log "hourly extract already running, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export PYTHONPATH="$APP_DIR/src:${PYTHONPATH:-}"
|
||||
|
||||
log "=== hourly extract+triage starting ==="
|
||||
|
||||
# Step A — Extract candidates from recent interactions
|
||||
log "Step A: LLM extraction (since last run)"
|
||||
python3 "$APP_DIR/scripts/batch_llm_extract_live.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
--limit "$LIMIT" \
|
||||
2>&1 || {
|
||||
log "WARN: batch extraction failed (non-blocking)"
|
||||
}
|
||||
|
||||
# Step B — 3-tier auto-triage (sonnet → opus → discard)
|
||||
log "Step B: auto-triage (3-tier)"
|
||||
python3 "$APP_DIR/scripts/auto_triage.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
--max-batches 3 \
|
||||
2>&1 || {
|
||||
log "WARN: auto-triage failed (non-blocking)"
|
||||
}
|
||||
|
||||
log "=== hourly extract+triage complete ==="
|
||||
197
deploy/hooks/capture_stop.py
Normal file
197
deploy/hooks/capture_stop.py
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Claude Code Stop hook: capture interaction to AtoCore.
|
||||
|
||||
Reads the Stop hook JSON from stdin, extracts the last user prompt
|
||||
from the transcript JSONL, and POSTs to the AtoCore /interactions
|
||||
endpoint with reinforcement enabled (no extraction).
|
||||
|
||||
Fail-open: always exits 0, logs errors to stderr only.
|
||||
|
||||
Environment variables:
|
||||
ATOCORE_URL Base URL of the AtoCore instance (default: http://dalidou:8100)
|
||||
ATOCORE_CAPTURE_DISABLED Set to "1" to disable capture (kill switch)
|
||||
|
||||
Usage in ~/.claude/settings.json:
|
||||
"Stop": [{
|
||||
"matcher": "",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "python /path/to/capture_stop.py",
|
||||
"timeout": 15
|
||||
}]
|
||||
}]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ATOCORE_URL = os.environ.get("ATOCORE_URL", "http://dalidou:8100")
|
||||
TIMEOUT_SECONDS = 10
|
||||
|
||||
# Minimum prompt length to bother capturing. Single-word acks,
|
||||
# slash commands, and empty lines aren't useful interactions.
|
||||
MIN_PROMPT_LENGTH = 15
|
||||
|
||||
# Maximum response length to capture. Truncate very long assistant
|
||||
# responses to keep the interactions table manageable.
|
||||
MAX_RESPONSE_LENGTH = 50_000
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point. Always exits 0."""
|
||||
try:
|
||||
_capture()
|
||||
except Exception as exc:
|
||||
print(f"capture_stop: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def _capture() -> None:
|
||||
if os.environ.get("ATOCORE_CAPTURE_DISABLED") == "1":
|
||||
return
|
||||
|
||||
raw = sys.stdin.read()
|
||||
if not raw.strip():
|
||||
return
|
||||
|
||||
hook_data = json.loads(raw)
|
||||
|
||||
session_id = hook_data.get("session_id", "")
|
||||
assistant_message = hook_data.get("last_assistant_message", "")
|
||||
transcript_path = hook_data.get("transcript_path", "")
|
||||
cwd = hook_data.get("cwd", "")
|
||||
|
||||
prompt = _extract_last_user_prompt(transcript_path)
|
||||
if not prompt or len(prompt.strip()) < MIN_PROMPT_LENGTH:
|
||||
return
|
||||
|
||||
response = assistant_message or ""
|
||||
if len(response) > MAX_RESPONSE_LENGTH:
|
||||
response = response[:MAX_RESPONSE_LENGTH] + "\n\n[truncated]"
|
||||
|
||||
project = _infer_project(cwd)
|
||||
|
||||
payload = {
|
||||
"prompt": prompt,
|
||||
"response": response,
|
||||
"client": "claude-code",
|
||||
"session_id": session_id,
|
||||
"project": project,
|
||||
"reinforce": True,
|
||||
}
|
||||
|
||||
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{ATOCORE_URL}/interactions",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=TIMEOUT_SECONDS)
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
print(
|
||||
f"capture_stop: recorded interaction {result.get('id', '?')} "
|
||||
f"(project={project or 'none'}, prompt_chars={len(prompt)}, "
|
||||
f"response_chars={len(response)})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def _extract_last_user_prompt(transcript_path: str) -> str:
|
||||
"""Read the JSONL transcript and return the last real user prompt.
|
||||
|
||||
Skips meta messages (isMeta=True) and system/command messages
|
||||
(content starting with '<').
|
||||
"""
|
||||
if not transcript_path:
|
||||
return ""
|
||||
|
||||
# Normalize path for the current OS
|
||||
path = os.path.normpath(transcript_path)
|
||||
if not os.path.isfile(path):
|
||||
return ""
|
||||
|
||||
last_prompt = ""
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if entry.get("type") != "user":
|
||||
continue
|
||||
if entry.get("isMeta", False):
|
||||
continue
|
||||
|
||||
msg = entry.get("message", {})
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
|
||||
content = msg.get("content", "")
|
||||
|
||||
if isinstance(content, str):
|
||||
text = content.strip()
|
||||
elif isinstance(content, list):
|
||||
# Content blocks: extract text blocks
|
||||
parts = []
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(block.get("text", ""))
|
||||
text = "\n".join(parts).strip()
|
||||
else:
|
||||
continue
|
||||
|
||||
# Skip system/command XML and very short messages
|
||||
if text.startswith("<") or len(text) < MIN_PROMPT_LENGTH:
|
||||
continue
|
||||
|
||||
last_prompt = text
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return last_prompt
|
||||
|
||||
|
||||
# Project inference from working directory.
|
||||
# Maps known repo paths to AtoCore project IDs. The user can extend
|
||||
# this table or replace it with a registry lookup later.
|
||||
_VAULT = "C:\\Users\\antoi\\antoine\\My Libraries\\Antoine Brain Extension"
|
||||
|
||||
_PROJECT_PATH_MAP: dict[str, str] = {
|
||||
f"{_VAULT}\\2-Projects\\P04-GigaBIT-M1": "p04-gigabit",
|
||||
f"{_VAULT}\\2-Projects\\P10-Interferometer": "p05-interferometer",
|
||||
f"{_VAULT}\\2-Projects\\P11-Polisher-Fullum": "p06-polisher",
|
||||
f"{_VAULT}\\2-Projects\\P08-ABB-Space-Mirror": "abb-space",
|
||||
f"{_VAULT}\\2-Projects\\I01-Atomizer": "atomizer-v2",
|
||||
f"{_VAULT}\\2-Projects\\I02-AtoCore": "atocore",
|
||||
"C:\\Users\\antoi\\ATOCore": "atocore",
|
||||
"C:\\Users\\antoi\\Polisher-Sim": "p06-polisher",
|
||||
"C:\\Users\\antoi\\Fullum-Interferometer": "p05-interferometer",
|
||||
"C:\\Users\\antoi\\Atomizer-V2": "atomizer-v2",
|
||||
}
|
||||
|
||||
|
||||
def _infer_project(cwd: str) -> str:
|
||||
"""Try to map the working directory to an AtoCore project."""
|
||||
if not cwd:
|
||||
return ""
|
||||
norm = os.path.normpath(cwd).lower()
|
||||
for path_prefix, project_id in _PROJECT_PATH_MAP.items():
|
||||
if norm.startswith(os.path.normpath(path_prefix).lower()):
|
||||
return project_id
|
||||
return ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
174
deploy/hooks/inject_context.py
Normal file
174
deploy/hooks/inject_context.py
Normal file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Claude Code UserPromptSubmit hook: inject AtoCore context.
|
||||
|
||||
Mirrors the OpenClaw 7I pattern on the Claude Code side. Every user
|
||||
prompt submitted to Claude Code is (a) sent to /context/build on the
|
||||
AtoCore API, and (b) the returned context pack is prepended to the
|
||||
prompt the LLM sees — so Claude Code answers grounded in what AtoCore
|
||||
already knows, same as OpenClaw now does.
|
||||
|
||||
Contract per Claude Code hooks spec:
|
||||
stdin: JSON with `prompt`, `session_id`, `transcript_path`, `cwd`,
|
||||
`hook_event_name`, etc.
|
||||
stdout on success: JSON
|
||||
{"hookSpecificOutput":
|
||||
{"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": "<pack>"}}
|
||||
exit 0 always — fail open. An unreachable AtoCore must never block
|
||||
the user's prompt.
|
||||
|
||||
Environment variables:
|
||||
ATOCORE_URL base URL (default http://dalidou:8100)
|
||||
ATOCORE_CONTEXT_DISABLED set to "1" to disable injection
|
||||
ATOCORE_CONTEXT_BUDGET max chars of injected pack (default 4000)
|
||||
ATOCORE_CONTEXT_TIMEOUT HTTP timeout in seconds (default 5)
|
||||
|
||||
Usage in ~/.claude/settings.json:
|
||||
"UserPromptSubmit": [{
|
||||
"matcher": "",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "python /path/to/inject_context.py",
|
||||
"timeout": 10
|
||||
}]
|
||||
}]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ATOCORE_URL = os.environ.get("ATOCORE_URL", "http://dalidou:8100")
|
||||
CONTEXT_TIMEOUT = float(os.environ.get("ATOCORE_CONTEXT_TIMEOUT", "5"))
|
||||
CONTEXT_BUDGET = int(os.environ.get("ATOCORE_CONTEXT_BUDGET", "4000"))
|
||||
|
||||
# Don't spend an API call on trivial acks or slash commands.
|
||||
MIN_PROMPT_LENGTH = 15
|
||||
|
||||
|
||||
# Project inference table — kept in sync with capture_stop.py so both
|
||||
# hooks agree on what project a Claude Code session belongs to.
|
||||
_VAULT = "C:\\Users\\antoi\\antoine\\My Libraries\\Antoine Brain Extension"
|
||||
_PROJECT_PATH_MAP: dict[str, str] = {
|
||||
f"{_VAULT}\\2-Projects\\P04-GigaBIT-M1": "p04-gigabit",
|
||||
f"{_VAULT}\\2-Projects\\P10-Interferometer": "p05-interferometer",
|
||||
f"{_VAULT}\\2-Projects\\P11-Polisher-Fullum": "p06-polisher",
|
||||
f"{_VAULT}\\2-Projects\\P08-ABB-Space-Mirror": "abb-space",
|
||||
f"{_VAULT}\\2-Projects\\I01-Atomizer": "atomizer-v2",
|
||||
f"{_VAULT}\\2-Projects\\I02-AtoCore": "atocore",
|
||||
"C:\\Users\\antoi\\ATOCore": "atocore",
|
||||
"C:\\Users\\antoi\\Polisher-Sim": "p06-polisher",
|
||||
"C:\\Users\\antoi\\Fullum-Interferometer": "p05-interferometer",
|
||||
"C:\\Users\\antoi\\Atomizer-V2": "atomizer-v2",
|
||||
}
|
||||
|
||||
|
||||
def _infer_project(cwd: str) -> str:
|
||||
if not cwd:
|
||||
return ""
|
||||
norm = os.path.normpath(cwd).lower()
|
||||
for path_prefix, project_id in _PROJECT_PATH_MAP.items():
|
||||
if norm.startswith(os.path.normpath(path_prefix).lower()):
|
||||
return project_id
|
||||
return ""
|
||||
|
||||
|
||||
def _emit_empty() -> None:
|
||||
"""Exit 0 with no additionalContext — equivalent to no-op."""
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def _emit_context(pack: str) -> None:
|
||||
"""Write the hook output JSON and exit 0."""
|
||||
out = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": pack,
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(out))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if os.environ.get("ATOCORE_CONTEXT_DISABLED") == "1":
|
||||
_emit_empty()
|
||||
|
||||
try:
|
||||
raw = sys.stdin.read()
|
||||
if not raw.strip():
|
||||
_emit_empty()
|
||||
hook_data = json.loads(raw)
|
||||
except Exception as exc:
|
||||
# Bad stdin → nothing to do
|
||||
print(f"inject_context: bad stdin: {exc}", file=sys.stderr)
|
||||
_emit_empty()
|
||||
|
||||
prompt = (hook_data.get("prompt") or "").strip()
|
||||
cwd = hook_data.get("cwd", "")
|
||||
|
||||
if len(prompt) < MIN_PROMPT_LENGTH:
|
||||
_emit_empty()
|
||||
|
||||
# Skip meta / system prompts that start with '<' (XML tags etc.)
|
||||
if prompt.startswith("<"):
|
||||
_emit_empty()
|
||||
|
||||
project = _infer_project(cwd)
|
||||
|
||||
body = json.dumps({
|
||||
"prompt": prompt,
|
||||
"project": project,
|
||||
"char_budget": CONTEXT_BUDGET,
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{ATOCORE_URL}/context/build",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=CONTEXT_TIMEOUT)
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.URLError as exc:
|
||||
# AtoCore unreachable — fail open
|
||||
print(f"inject_context: atocore unreachable: {exc}", file=sys.stderr)
|
||||
_emit_empty()
|
||||
except Exception as exc:
|
||||
print(f"inject_context: request failed: {exc}", file=sys.stderr)
|
||||
_emit_empty()
|
||||
|
||||
pack = (data.get("formatted_context") or "").strip()
|
||||
if not pack:
|
||||
_emit_empty()
|
||||
|
||||
# Safety truncate. /context/build respects the budget we sent, but
|
||||
# be defensive in case of a regression.
|
||||
if len(pack) > CONTEXT_BUDGET + 500:
|
||||
pack = pack[:CONTEXT_BUDGET] + "\n\n[context truncated]"
|
||||
|
||||
# Wrap so the LLM knows this is injected grounding, not user text.
|
||||
wrapped = (
|
||||
"---\n"
|
||||
"AtoCore-injected context for this prompt "
|
||||
f"(project={project or '(none)'}):\n\n"
|
||||
f"{pack}\n"
|
||||
"---"
|
||||
)
|
||||
|
||||
print(
|
||||
f"inject_context: injected {len(pack)} chars "
|
||||
f"(project={project or 'none'}, prompt_chars={len(prompt)})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
_emit_context(wrapped)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
284
docs/MASTER-BRAIN-PLAN.md
Normal file
284
docs/MASTER-BRAIN-PLAN.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# AtoCore Master Brain Plan
|
||||
|
||||
> Vision: AtoCore becomes the **single source of truth** that grounds every LLM
|
||||
> interaction across the entire ecosystem (Claude, OpenClaw, Codex, Ollama, future
|
||||
> agents). Every prompt is automatically enriched with full project context. The
|
||||
> brain self-grows from daily work, auto-organizes its metadata, and stays
|
||||
> flawlessly reliable.
|
||||
|
||||
## The Core Insight
|
||||
|
||||
AtoCore today is a **well-architected capture + curation system with a critical
|
||||
gap on the consumption side**. We pour water into the bucket (capture from
|
||||
Claude Code Stop hook + OpenClaw message hooks) but nothing is drinking from it
|
||||
at prompt time. Fixing that gap is the single highest-leverage move.
|
||||
|
||||
**Once every LLM call is AtoCore-grounded automatically, the feedback loop
|
||||
closes**: LLMs use the context → produce better responses → those responses
|
||||
reference the injected memories → reinforcement fires → knowledge curates
|
||||
itself. The capture side is already working. The pull side is what's missing.
|
||||
|
||||
## Universal Consumption Strategy
|
||||
|
||||
MCP is great for Claude (Claude Desktop, Claude Code, Cursor, Zed, Windsurf) but
|
||||
is **not universal**. OpenClaw has its own plugin SDK. Codex, Ollama, and GPT
|
||||
don't natively support MCP. The right strategy:
|
||||
|
||||
**HTTP API is the truth; every client gets the thinnest possible adapter.**
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ AtoCore HTTP API │ ← canonical interface
|
||||
│ /context/build │
|
||||
│ /query │
|
||||
│ /memory │
|
||||
│ /project/state │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌────────────┬───────────┼──────────┬────────────┐
|
||||
│ │ │ │ │
|
||||
┌──┴───┐ ┌────┴────┐ ┌───┴───┐ ┌───┴────┐ ┌───┴────┐
|
||||
│ MCP │ │OpenClaw │ │Claude │ │ Codex │ │ Ollama │
|
||||
│server│ │ plugin │ │ Code │ │ skill │ │ proxy │
|
||||
│ │ │ (pull) │ │ hook │ │ │ │ │
|
||||
└──┬───┘ └────┬────┘ └───┬───┘ └────┬───┘ └────┬───┘
|
||||
│ │ │ │ │
|
||||
Claude OpenClaw Claude Code Codex CLI Ollama
|
||||
Desktop, agent local
|
||||
Cursor, models
|
||||
Zed,
|
||||
Windsurf
|
||||
```
|
||||
|
||||
Each adapter's only job: accept a prompt, call AtoCore HTTP, prepend the
|
||||
returned context pack. The adapter itself carries no logic.
|
||||
|
||||
## Three Integration Tiers
|
||||
|
||||
### Tier 1: MCP-native clients (Claude ecosystem)
|
||||
Build **atocore-mcp** — a standalone MCP server that wraps the HTTP API. Exposes:
|
||||
- `context(query, project)` → context pack
|
||||
- `search(query)` → raw retrieval
|
||||
- `remember(type, content, project)` → create candidate memory
|
||||
- `recall(project, key)` → project state lookup
|
||||
- `list_projects()` → registered projects
|
||||
|
||||
Works with Claude Desktop, Claude Code (via `claude mcp add atocore`), Cursor,
|
||||
Zed, Windsurf without any per-client work beyond config.
|
||||
|
||||
### Tier 2: Custom plugin ecosystems (OpenClaw)
|
||||
Extend the existing `atocore-capture` plugin on T420 to also register a
|
||||
**`before_prompt_build`** hook that pulls context from AtoCore and injects it
|
||||
into the agent's system prompt. The plugin already has the HTTP client, the
|
||||
authentication, the fail-open pattern. This is ~30 lines of added code.
|
||||
|
||||
### Tier 3: Everything else (Codex, Ollama, custom agents)
|
||||
For clients without plugin/hook systems, ship a **thin proxy/middleware** the
|
||||
user configures as the LLM endpoint:
|
||||
- `atocore-proxy` listens on `localhost:PORT`
|
||||
- Intercepts OpenAI-compatible chat/completion calls
|
||||
- Pulls context from AtoCore, injects into system prompt
|
||||
- Forwards to the real model endpoint (OpenAI, Ollama, Anthropic, etc.)
|
||||
- Returns the response, then captures the interaction back to AtoCore
|
||||
|
||||
This makes AtoCore a "drop-in" layer for anything that speaks
|
||||
OpenAI-compatible HTTP — which is nearly every modern LLM runtime.
|
||||
|
||||
## Knowledge Density Plan
|
||||
|
||||
The brain is only as smart as what it knows. Current state: 80 active memories
|
||||
across 6 projects, 324 candidates in the queue being processed. Target:
|
||||
**1,000+ curated memories** to become a real master brain.
|
||||
|
||||
Mechanisms:
|
||||
1. **Finish the current triage pass** (324 → ~80 more promotions expected).
|
||||
2. **Re-extract with stronger prompt on existing 236 interactions** — tune the
|
||||
LLM extractor system prompt to pull more durable facts and fewer ephemeral
|
||||
snapshots.
|
||||
3. **Ingest all drive/vault documents as memory candidates** (not just chunks).
|
||||
Every structured markdown section with a decision/fact/requirement header
|
||||
becomes a candidate memory.
|
||||
4. **Multi-source triangulation**: same fact in 3+ sources = auto-promote to
|
||||
confidence 0.95.
|
||||
5. **Cross-project synthesis**: facts appearing in multiple project contexts
|
||||
get promoted to global domain knowledge.
|
||||
|
||||
## Auto-Organization of Metadata
|
||||
|
||||
Currently: `type`, `project`, `confidence`, `status`, `reference_count`. For
|
||||
master brain we need more structure, inferred automatically:
|
||||
|
||||
| Addition | Purpose | Mechanism |
|
||||
|---|---|---|
|
||||
| **Domain tags** (optics, mechanics, firmware, business…) | Cross-cutting retrieval | LLM inference during triage |
|
||||
| **Temporal scope** (permanent, valid_until_X, transient) | Avoid stale truth | LLM classifies during triage |
|
||||
| **Source refs** (chunk_id[], interaction_id[]) | Provenance for every fact | Enforced at creation time |
|
||||
| **Relationships** (contradicts, updates, depends_on) | Memory graph | Triage infers during review |
|
||||
| **Semantic clusters** | Detect duplicates, find gaps | Weekly HDBSCAN pass on embeddings |
|
||||
|
||||
Layer these in progressively — none of them require schema rewrites, just
|
||||
additional fields and batch jobs.
|
||||
|
||||
## Self-Growth Mechanisms
|
||||
|
||||
Four loops that make AtoCore grow autonomously:
|
||||
|
||||
### 1. Drift detection (nightly)
|
||||
Compare new chunk embeddings to existing vector distribution. Centroids >X
|
||||
cosine distance from any existing centroid = new knowledge area. Log to
|
||||
dashboard; human decides if it's noise or a domain worth curating.
|
||||
|
||||
### 2. Gap identification (continuous)
|
||||
Every `/context/build` logs `query + chunks_returned + memories_returned`.
|
||||
Weekly report: "top 10 queries with weak coverage." Those are targeted
|
||||
curation opportunities.
|
||||
|
||||
### 3. Multi-source triangulation (weekly)
|
||||
Scan memory content similarity across sources. When a fact appears in 3+
|
||||
independent sources (vault doc + drive doc + interaction), auto-promote to
|
||||
high confidence and mark as "triangulated."
|
||||
|
||||
### 4. Active learning prompts (monthly)
|
||||
Surface "you have 200 p06 memories but only 15 p04 memories. Spend 30 min
|
||||
curating p04?" via dashboard digest.
|
||||
|
||||
## Robustness Strategy (Flawless Operation Bar)
|
||||
|
||||
Current: nightly backup, off-host rsync, health endpoint, 303 tests, harness,
|
||||
enhanced dashboard with pipeline health (this session).
|
||||
|
||||
To reach "flawless":
|
||||
|
||||
| Gap | Fix | Priority |
|
||||
|---|---|---|
|
||||
| Silent pipeline failures | Alerting webhook on harness drop / pipeline skip | P1 |
|
||||
| Memory mutations untracked | Append-only audit log table | P1 |
|
||||
| Integrity drift | Nightly FK + vector-chunk parity checks | P1 |
|
||||
| Schema migrations ad-hoc | Formal migration framework with rollback | P2 |
|
||||
| Single point of failure | Daily backup to user's main computer (new) | P1 |
|
||||
| No hot standby | Second instance following primary via WAL | P3 |
|
||||
| No temporal history | Memory audit + valid_until fields | P2 |
|
||||
|
||||
### Daily Backup to Main Computer
|
||||
|
||||
Currently: Dalidou → T420 (192.168.86.39) via rsync.
|
||||
|
||||
Add: Dalidou → main computer via a pull (main computer runs the rsync,
|
||||
pulls from Dalidou). Pull-based is simpler than push — no need for SSH
|
||||
keys on Dalidou to reach the Windows machine.
|
||||
|
||||
```bash
|
||||
# On main computer, daily scheduled task:
|
||||
rsync -a papa@dalidou:/srv/storage/atocore/backups/snapshots/ \
|
||||
/path/to/local/atocore-backups/
|
||||
```
|
||||
|
||||
Configure via Windows Task Scheduler or a cron-like runner. Verify weekly
|
||||
that the latest snapshot is present.
|
||||
|
||||
## Human Interface Auto-Evolution
|
||||
|
||||
Current: wiki at `/wiki`, regenerates on every request from DB. Synthesis
|
||||
(the "current state" paragraph at top of project pages) runs **weekly on
|
||||
Sundays only**. That's why it feels stalled.
|
||||
|
||||
Fixes:
|
||||
1. **Run synthesis daily, not weekly.** It's cheap (one claude call per
|
||||
project) and keeps the human-readable overview fresh.
|
||||
2. **Trigger synthesis on major events** — when 5+ new memories land for a
|
||||
project, regenerate its synthesis.
|
||||
3. **Add "What's New" feed** — wiki homepage shows recent additions across all
|
||||
projects (last 7 days of memory promotions, state entries, entities).
|
||||
4. **Memory timeline view** — project page gets a chronological list of what
|
||||
we learned when.
|
||||
|
||||
## Phased Roadmap (8-10 weeks)
|
||||
|
||||
### Phase 1 (week 1-2): Universal Consumption
|
||||
**Goal: every LLM call is AtoCore-grounded automatically.**
|
||||
|
||||
- [ ] Build `atocore-mcp` server (wraps HTTP API, stdio transport)
|
||||
- [ ] Publish to npm / or run via `pipx` / stdlib HTTP
|
||||
- [ ] Configure in Claude Desktop (`~/.claude/mcp_servers.json`)
|
||||
- [ ] Configure in Claude Code (`claude mcp add atocore …`)
|
||||
- [ ] Extend OpenClaw plugin with `before_prompt_build` PULL
|
||||
- [ ] Write `atocore-proxy` middleware for Codex/Ollama/generic clients
|
||||
- [ ] Document configuration for each client
|
||||
|
||||
**Success:** open a fresh Claude Code session, ask a project question, verify
|
||||
the response references AtoCore memories without manual context commands.
|
||||
|
||||
### Phase 2 (week 2-3): Knowledge Density + Wiki Evolution
|
||||
- [ ] Finish current triage pass (324 candidates → active)
|
||||
- [ ] Tune extractor prompt for higher promotion rate on durable facts
|
||||
- [ ] Daily synthesis in cron (not just Sundays)
|
||||
- [ ] Event-triggered synthesis on significant project changes
|
||||
- [ ] Wiki "What's New" feed
|
||||
- [ ] Memory timeline per project
|
||||
|
||||
**Target:** 300+ active memories, wiki feels alive daily.
|
||||
|
||||
### Phase 3 (week 3-4): Auto-Organization
|
||||
- [ ] Schema: add `domain_tags`, `valid_until`, `source_refs`, `triangulated_count`
|
||||
- [ ] Triage prompt upgraded: infer tags + temporal scope + relationships
|
||||
- [ ] Weekly HDBSCAN clustering of embeddings → dup detection + gap reports
|
||||
- [ ] Relationship edges in a new `memory_relationships` table
|
||||
|
||||
### Phase 4 (week 4-5): Robustness Hardening
|
||||
- [ ] Append-only `memory_audit` table + retrofit mutations
|
||||
- [ ] Nightly integrity checks (FK validation, orphan detection, parity)
|
||||
- [ ] Alerting webhook (Discord/email) on pipeline anomalies
|
||||
- [ ] Daily backup to user's main computer (pull-based)
|
||||
- [ ] Formal migration framework
|
||||
|
||||
### Phase 5 (week 6-7): Engineering V1 Implementation
|
||||
Execute the 23 acceptance criteria in `docs/architecture/engineering-v1-acceptance.md`
|
||||
against p06-polisher as the test bed. The ontology and queries are designed;
|
||||
this phase implements them.
|
||||
|
||||
### Phase 6 (week 8-9): Self-Growth Loops
|
||||
- [ ] Drift detection (nightly)
|
||||
- [ ] Gap identification from `/context/build` logs
|
||||
- [ ] Multi-source triangulation
|
||||
- [ ] Active learning digest (monthly)
|
||||
- [ ] Cross-project synthesis
|
||||
|
||||
### Phase 7 (ongoing): Scale & Polish
|
||||
- [ ] Multi-model validation (sonnet triages, opus cross-checks on disagreements)
|
||||
- [ ] AtoDrive integration (Google Drive as trusted source)
|
||||
- [ ] Hot standby when real production dependence materializes
|
||||
- [ ] More MCP tools (write-back, memory search, entity queries)
|
||||
|
||||
## Success Criteria
|
||||
|
||||
AtoCore is a master brain when:
|
||||
|
||||
1. **Zero manual context commands.** A fresh Claude/OpenClaw session answering
|
||||
a project question without being told "use AtoCore context."
|
||||
2. **1,000+ active memories** with >90% provenance coverage (every fact
|
||||
traceable to a source).
|
||||
3. **Every project has a current, human-readable overview** updated within 24h
|
||||
of significant changes.
|
||||
4. **Harness stays >95%** across 20+ fixtures covering all active projects.
|
||||
5. **Zero silent pipeline failures** for 30 consecutive days (all failures
|
||||
surface via alert within the hour).
|
||||
6. **Claude on any task knows what we know** — user asks "what did we decide
|
||||
about X?" and the answer is grounded in AtoCore, not reconstructed from
|
||||
scratch.
|
||||
|
||||
## Where We Are Now (2026-04-16)
|
||||
|
||||
- ✅ Core infrastructure: HTTP API, SQLite, Chroma, deploy pipeline
|
||||
- ✅ Capture pipes: Claude Code Stop hook, OpenClaw message hooks
|
||||
- ✅ Nightly pipeline: backup, extract, triage, synthesis, lint, harness, summary
|
||||
- ✅ Phase 10: auto-promotion from reinforcement + candidate expiry
|
||||
- ✅ Dashboard shows pipeline health + interaction totals + all projects
|
||||
- ⚡ 324 candidates being triaged (down from 439), ~80 active memories, growing
|
||||
- ❌ No consumption at prompt time (capture-only)
|
||||
- ❌ Wiki auto-evolves only on Sundays (synthesis cadence)
|
||||
- ❌ No MCP adapter
|
||||
- ❌ No daily backup to main computer
|
||||
- ❌ Engineering V1 not implemented
|
||||
- ❌ No alerting on pipeline failures
|
||||
|
||||
The path is clear. Phase 1 is the keystone.
|
||||
96
docs/PHASE-7-MEMORY-CONSOLIDATION.md
Normal file
96
docs/PHASE-7-MEMORY-CONSOLIDATION.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Phase 7 — Memory Consolidation (the "Sleep Cycle")
|
||||
|
||||
**Status**: 7A in progress · 7B-H scoped, deferred
|
||||
**Design principle**: *"Like human memory while sleeping, but more robotic — never discard relevant details. Consolidate, update, supersede — don't delete."*
|
||||
|
||||
## Why
|
||||
|
||||
Phases 1–6 built capture + triage + graduation + emerging-project detection. What they don't solve:
|
||||
|
||||
| # | Problem | Fix |
|
||||
|---|---|---|
|
||||
| 1 | Redundancy — "APM uses NX" said 5 different ways across 5 memories | **7A** Semantic dedup |
|
||||
| 2 | Latent contradictions — "chose Zygo" + "switched from Zygo" both active | **7B** Pair contradiction detection |
|
||||
| 3 | Tag drift — `firmware`, `fw`, `firmware-control` fragment retrieval | **7C** Tag canonicalization |
|
||||
| 4 | Confidence staleness — 6-month unreferenced memory ranks as fresh | **7D** Confidence decay |
|
||||
| 5 | No memory drill-down page | **7E** `/wiki/memories/{id}` |
|
||||
| 6 | Domain knowledge siloed per project | **7F** `/wiki/domains/{tag}` |
|
||||
| 7 | Prompt upgrades (llm-0.5 → 0.6) don't re-process old interactions | **7G** Re-extraction on version bump |
|
||||
| 8 | Superseded memory vectors still in Chroma polluting retrieval | **7H** Vector hygiene |
|
||||
|
||||
Collectively: the brain needs a nightly pass that looks at what it already knows and tidies up — dedup, resolve contradictions, canonicalize tags, decay stale facts — **without losing information**.
|
||||
|
||||
## Subphases
|
||||
|
||||
### 7A — Semantic dedup + consolidation *(this sprint)*
|
||||
|
||||
Compute embeddings on active memories, find pairs within `(project, memory_type)` bucket above similarity threshold (default 0.88), cluster, draft a unified memory via LLM, human approves in triage UI. On approve: sources become `superseded`, new merged memory created with union of `source_refs`, sum of `reference_count`, max of `confidence`. **Ships first** because redundancy compounds — every new memory potentially duplicates an old one.
|
||||
|
||||
Detailed spec lives in the working plan (`dapper-cooking-tower.md`) and across the files listed under "Files touched" below. Key decisions:
|
||||
|
||||
- LLM drafts, human approves — no silent auto-merge.
|
||||
- Same `(project, memory_type)` bucket only. Cross-project merges are rare + risky → separate flow in 7B.
|
||||
- Recompute embeddings each scan (~2s / 335 memories). Persist only if scan time becomes a problem.
|
||||
- Cluster-based proposals (A~B~C → one merge), not pair-based.
|
||||
- `status=superseded` never deleted — still queryable with filter.
|
||||
|
||||
**Schema**: new table `memory_merge_candidates` (pending | approved | rejected).
|
||||
**Cron**: nightly at threshold 0.90 (tight); weekly (Sundays) at 0.85 (deeper cleanup).
|
||||
**UI**: new "🔗 Merge Candidates" section in `/admin/triage`.
|
||||
|
||||
**Files touched in 7A**:
|
||||
- `src/atocore/models/database.py` — migration
|
||||
- `src/atocore/memory/similarity.py` — new, `compute_memory_similarity()`
|
||||
- `src/atocore/memory/_dedup_prompt.py` — new, shared LLM prompt
|
||||
- `src/atocore/memory/service.py` — `merge_memories()`
|
||||
- `scripts/memory_dedup.py` — new, host-side detector (HTTP-only)
|
||||
- `src/atocore/api/routes.py` — 5 new endpoints under `/admin/memory/`
|
||||
- `src/atocore/engineering/triage_ui.py` — merge cards section
|
||||
- `deploy/dalidou/batch-extract.sh` — Step B3
|
||||
- `deploy/dalidou/dedup-watcher.sh` — new, UI-triggered scans
|
||||
- `tests/test_memory_dedup.py` — ~10-15 new tests
|
||||
|
||||
### 7B — Memory-to-memory contradiction detection
|
||||
|
||||
Same embedding-pair machinery as 7A but within a *different* band (similarity 0.70–0.88 — semantically related but different wording). LLM classifies each pair: `duplicate | complementary | contradicts | supersedes-older`. Contradictions write a `memory_conflicts` row + surface a triage badge. Clear supersessions (both tier 1 sonnet and tier 2 opus agree) auto-mark the older as `superseded`.
|
||||
|
||||
### 7C — Tag canonicalization
|
||||
|
||||
Weekly LLM pass over `domain_tags` distribution, proposes `alias → canonical` map (e.g. `fw → firmware`). Human approves via UI (one-click pattern, same as emerging-project registration). Bulk-rewrites `domain_tags` atomically across all memories.
|
||||
|
||||
### 7D — Confidence decay
|
||||
|
||||
Daily lightweight job. For memories with `reference_count=0` AND `last_referenced_at` older than 30 days: multiply confidence by 0.97/day (~2-month half-life). Reinforcement already bumps confidence. Below 0.3 → auto-supersede with reason `decayed, no references`. Reversible (tune half-life), non-destructive (still searchable with status filter).
|
||||
|
||||
### 7E — Memory detail page `/wiki/memories/{id}`
|
||||
|
||||
Provenance chain: source_chunk → interaction → graduated_to_entity. Audit trail (Phase 4 has the data). Related memories (same project + tag + semantic neighbors). Decay trajectory plot (if 7D ships). Link target from every memory surfaced anywhere in the wiki.
|
||||
|
||||
### 7F — Cross-project domain view `/wiki/domains/{tag}`
|
||||
|
||||
One page per `domain_tag` showing all memories + graduated entities with that tag, grouped by project. "Optics across p04+p05+p06" becomes a real navigable page. Answers the long-standing question the tag system was meant to enable.
|
||||
|
||||
### 7G — Re-extraction on prompt upgrade
|
||||
|
||||
`batch_llm_extract_live.py --force-reextract --since DATE`. Dedupe key: `(interaction_id, extractor_version)` — same run on same interaction doesn't double-create. Triggered manually when `LLM_EXTRACTOR_VERSION` bumps. Not automatic (destructive).
|
||||
|
||||
### 7H — Vector store hygiene
|
||||
|
||||
Nightly: scan `source_chunks` and `memory_embeddings` (added in 7A V2) for `status=superseded|invalid`. Delete matching vectors from Chroma. Fail-open — the retrieval harness catches any real regression.
|
||||
|
||||
## Verification & ship order
|
||||
|
||||
1. **7A** — ship + observe 1 week → validate merge proposals are high-signal, rejection rate acceptable
|
||||
2. **7D** — decay is low-risk + high-compounding value; ship second
|
||||
3. **7C** — clean up tag fragmentation before 7F depends on canonical tags
|
||||
4. **7E** + **7F** — UX surfaces; ship together once data is clean
|
||||
5. **7B** — contradictions flow (pairs harder than duplicates to classify; wait for 7A data to tune threshold)
|
||||
6. **7G** — on-demand; no ship until we actually bump the extractor prompt
|
||||
7. **7H** — housekeeping; after 7A + 7B + 7D have generated enough `superseded` rows to matter
|
||||
|
||||
## Scope NOT in Phase 7
|
||||
|
||||
- Graduated memories (entity-descended) are **frozen** — exempt from dedup/decay. Entity consolidation is a separate Phase (8+).
|
||||
- Auto-merging without human approval (always human-in-the-loop in V1).
|
||||
- Summarization / compression — a different problem (reducing the number of chunks per memory, not the number of memories).
|
||||
- Forgetting policies — there's no user-facing "delete this" flow in Phase 7. Supersede + filter covers the need.
|
||||
434
docs/architecture/engineering-v1-acceptance.md
Normal file
434
docs/architecture/engineering-v1-acceptance.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# Engineering Layer V1 Acceptance Criteria
|
||||
|
||||
## Why this document exists
|
||||
|
||||
The engineering layer planning sprint produced 7 architecture
|
||||
docs. None of them on their own says "you're done with V1, ship
|
||||
it". This document does. It translates the planning into
|
||||
measurable, falsifiable acceptance criteria so the implementation
|
||||
sprint can know unambiguously when V1 is complete.
|
||||
|
||||
The acceptance criteria are organized into four categories:
|
||||
|
||||
1. **Functional** — what the system must be able to do
|
||||
2. **Quality** — how well it must do it
|
||||
3. **Operational** — what running it must look like
|
||||
4. **Documentation** — what must be written down
|
||||
|
||||
V1 is "done" only when **every criterion in this document is met
|
||||
against at least one of the three active projects** (`p04-gigabit`,
|
||||
`p05-interferometer`, `p06-polisher`). The choice of which
|
||||
project is the test bed is up to the implementer, but the same
|
||||
project must satisfy all functional criteria.
|
||||
|
||||
## The single-sentence definition
|
||||
|
||||
> AtoCore Engineering Layer V1 is done when, against one chosen
|
||||
> active project, every v1-required query in
|
||||
> `engineering-query-catalog.md` returns a correct result, the
|
||||
> Human Mirror renders a coherent project overview, and a real
|
||||
> KB-CAD or KB-FEM export round-trips through the ingest →
|
||||
> review queue → active entity flow without violating any
|
||||
> conflict or trust invariant.
|
||||
|
||||
Everything below is the operational form of that sentence.
|
||||
|
||||
## Category 1 — Functional acceptance
|
||||
|
||||
### F-1: Entity store implemented per the V1 ontology
|
||||
|
||||
- The 12 V1 entity types from `engineering-ontology-v1.md` exist
|
||||
in the database with the schema described there
|
||||
- The 4 relationship families (Structural, Intent, Validation,
|
||||
Provenance) are implemented as edges with the relationship
|
||||
types listed in the catalog
|
||||
- Every entity has the shared header fields:
|
||||
`id, type, name, project_id, status, confidence, source_refs,
|
||||
created_at, updated_at, extractor_version, canonical_home`
|
||||
- The status lifecycle matches the memory layer:
|
||||
`candidate → active → superseded | invalid`
|
||||
|
||||
### F-2: All v1-required queries return correct results
|
||||
|
||||
For the chosen test project, every query Q-001 through Q-020 in
|
||||
`engineering-query-catalog.md` must:
|
||||
|
||||
- be implemented as an API endpoint with the shape specified in
|
||||
the catalog
|
||||
- return the expected result shape against real data
|
||||
- include the provenance chain when the catalog requires it
|
||||
- handle the empty case (no matches) gracefully — empty array,
|
||||
not 500
|
||||
|
||||
The "killer correctness queries" — Q-006 (orphan requirements),
|
||||
Q-009 (decisions on flagged assumptions), Q-011 (unsupported
|
||||
validation claims) — are non-negotiable. If any of those three
|
||||
returns wrong results, V1 is not done.
|
||||
|
||||
### F-3: Tool ingest endpoints are live
|
||||
|
||||
Both endpoints from `tool-handoff-boundaries.md` are implemented:
|
||||
|
||||
- `POST /ingest/kb-cad/export` accepts the documented JSON
|
||||
shape, validates it, and produces entity candidates
|
||||
- `POST /ingest/kb-fem/export` ditto
|
||||
- Both refuse exports with invalid schemas (4xx with a clear
|
||||
error)
|
||||
- Both return a summary of created/dropped/failed counts
|
||||
- Both never auto-promote anything; everything lands as
|
||||
`status="candidate"`
|
||||
- Both carry source identifiers (exporter name, exporter version,
|
||||
source artifact id) into the candidate's provenance fields
|
||||
|
||||
A real KB-CAD export — even a hand-crafted one if the actual
|
||||
exporter doesn't exist yet — must round-trip through the endpoint
|
||||
and produce reviewable candidates for the test project.
|
||||
|
||||
### F-4: Candidate review queue works end to end
|
||||
|
||||
Per `promotion-rules.md`:
|
||||
|
||||
- `GET /entities?status=candidate` lists the queue
|
||||
- `POST /entities/{id}/promote` moves candidate → active
|
||||
- `POST /entities/{id}/reject` moves candidate → invalid
|
||||
- The same shapes work for memories (already shipped in Phase 9 C)
|
||||
- The reviewer can edit a candidate's content via
|
||||
`PUT /entities/{id}` before promoting
|
||||
- Every promote/reject is logged with timestamp and reason
|
||||
|
||||
### F-5: Conflict detection fires
|
||||
|
||||
Per `conflict-model.md`:
|
||||
|
||||
- The synchronous detector runs at every active write
|
||||
(create, promote, project_state set, KB import)
|
||||
- A test must demonstrate that pushing a contradictory KB-CAD
|
||||
export creates a `conflicts` row with both members linked
|
||||
- The reviewer can resolve the conflict via
|
||||
`POST /conflicts/{id}/resolve` with one of the supported
|
||||
actions (supersede_others, no_action, dismiss)
|
||||
- Resolution updates the underlying entities according to the
|
||||
chosen action
|
||||
|
||||
### F-6: Human Mirror renders for the test project
|
||||
|
||||
Per `human-mirror-rules.md`:
|
||||
|
||||
- `GET /mirror/{project}/overview` returns rendered markdown
|
||||
- `GET /mirror/{project}/decisions` returns rendered markdown
|
||||
- `GET /mirror/{project}/subsystems/{subsystem}` returns
|
||||
rendered markdown for at least one subsystem
|
||||
- `POST /mirror/{project}/regenerate` triggers regeneration on
|
||||
demand
|
||||
- Generated files appear under `/srv/storage/atocore/data/mirror/`
|
||||
with the "do not edit" header banner
|
||||
- Disputed markers appear inline when conflicts exist
|
||||
- Project-state overrides display with the `(curated)` annotation
|
||||
- Output is deterministic (the same inputs produce the same
|
||||
bytes, suitable for diffing)
|
||||
|
||||
### F-7: Memory-to-entity graduation works for at least one type
|
||||
|
||||
Per `memory-vs-entities.md`:
|
||||
|
||||
- `POST /memory/{id}/graduate` exists
|
||||
- Graduating a memory of type `adaptation` produces a Decision
|
||||
entity candidate with the memory's content as a starting point
|
||||
- The original memory row stays at `status="graduated"` (a new
|
||||
status added by the engineering layer migration)
|
||||
- The graduated memory has a forward pointer to the entity
|
||||
candidate's id
|
||||
- Promoting the entity candidate does NOT delete the original
|
||||
memory
|
||||
- The same graduation flow works for `project` → Requirement
|
||||
and `knowledge` → Fact entity types (test the path; doesn't
|
||||
have to be exhaustive)
|
||||
|
||||
### F-8: Provenance chain is complete
|
||||
|
||||
For every active entity in the test project, the following must
|
||||
be true:
|
||||
|
||||
- It links back to at least one source via `source_refs` (which
|
||||
is one or more of: source_chunk_id, source_interaction_id,
|
||||
source_artifact_id from KB import)
|
||||
- The provenance chain can be walked from the entity to the
|
||||
underlying raw text (source_chunks) or external artifact
|
||||
- Q-017 (the evidence query) returns at least one row for every
|
||||
active entity
|
||||
|
||||
If any active entity has no provenance, it's a bug — provenance
|
||||
is mandatory at write time per the promotion rules.
|
||||
|
||||
## Category 2 — Quality acceptance
|
||||
|
||||
### Q-1: All existing tests still pass
|
||||
|
||||
The full pre-V1 test suite (currently 160 tests) must still
|
||||
pass. The V1 implementation may add new tests but cannot regress
|
||||
any existing test.
|
||||
|
||||
### Q-2: V1 has its own test coverage
|
||||
|
||||
For each of F-1 through F-8 above, at least one automated test
|
||||
exists that:
|
||||
|
||||
- exercises the happy path
|
||||
- covers at least one error path
|
||||
- runs in CI in under 10 seconds (no real network, no real LLM)
|
||||
|
||||
The full V1 test suite should be under 30 seconds total runtime
|
||||
to keep the development loop fast.
|
||||
|
||||
### Q-3: Conflict invariants are enforced by tests
|
||||
|
||||
Specific tests must demonstrate:
|
||||
|
||||
- Two contradictory KB exports produce a conflict (not silent
|
||||
overwrite)
|
||||
- A reviewer can't accidentally promote both members of an open
|
||||
conflict to active without resolving the conflict first
|
||||
- The "flag, never block" rule holds — writes still succeed
|
||||
even when they create a conflict
|
||||
|
||||
### Q-4: Trust hierarchy is enforced by tests
|
||||
|
||||
Specific tests must demonstrate:
|
||||
|
||||
- Entity candidates can never appear in context packs
|
||||
- Reinforcement only touches active memories (already covered
|
||||
by Phase 9 Commit B tests, but the same property must hold
|
||||
for entities once they exist)
|
||||
- Nothing automatically writes to project_state ever
|
||||
- Candidates can never satisfy Q-005 (only active entities count)
|
||||
|
||||
### Q-5: The Human Mirror is reproducible
|
||||
|
||||
A golden-file test exists for at least one Mirror page. Updating
|
||||
the golden file is a normal part of template work (single
|
||||
command, well-documented). The test fails if the renderer
|
||||
produces different bytes for the same input, catching
|
||||
non-determinism.
|
||||
|
||||
### Q-6: Killer correctness queries pass against real-ish data
|
||||
|
||||
The test bed for Q-006, Q-009, Q-011 is not synthetic. The
|
||||
implementation must seed the test project with at least:
|
||||
|
||||
- One Requirement that has a satisfying Component (Q-006 should
|
||||
not flag it)
|
||||
- One Requirement with no satisfying Component (Q-006 must flag it)
|
||||
- One Decision based on an Assumption flagged as `needs_review`
|
||||
(Q-009 must flag the Decision)
|
||||
- One ValidationClaim with at least one supporting Result
|
||||
(Q-011 should not flag it)
|
||||
- One ValidationClaim with no supporting Result (Q-011 must flag it)
|
||||
|
||||
These five seed cases run as a single integration test that
|
||||
exercises the killer correctness queries against actual
|
||||
representative data.
|
||||
|
||||
## Category 3 — Operational acceptance
|
||||
|
||||
### O-1: Migration is safe and reversible
|
||||
|
||||
The V1 schema migration (adding the `entities`, `relationships`,
|
||||
`conflicts`, `conflict_members` tables, plus `mirror_regeneration_failures`)
|
||||
must:
|
||||
|
||||
- run cleanly against a production-shape database
|
||||
- be implemented via the same `_apply_migrations` pattern as
|
||||
Phase 9 (additive only, idempotent, safe to run twice)
|
||||
- be tested by spinning up a fresh DB AND running against a
|
||||
copy of the live Dalidou DB taken from a backup
|
||||
|
||||
### O-2: Backup and restore still work
|
||||
|
||||
The backup endpoint must include the new tables. A restore drill
|
||||
on the test project must:
|
||||
|
||||
- successfully back up the V1 entity state via
|
||||
`POST /admin/backup`
|
||||
- successfully validate the snapshot
|
||||
- successfully restore from the snapshot per
|
||||
`docs/backup-restore-procedure.md`
|
||||
- pass post-restore verification including a Q-001 query against
|
||||
the test project
|
||||
|
||||
The drill must be performed once before V1 is declared done.
|
||||
|
||||
### O-3: Performance bounds
|
||||
|
||||
These are starting bounds; tune later if real usage shows
|
||||
problems:
|
||||
|
||||
- Single-entity write (`POST /entities/...`): under 100ms p99
|
||||
on the production Dalidou hardware
|
||||
- Single Q-001 / Q-005 / Q-008 query: under 500ms p99 against
|
||||
a project with up to 1000 entities
|
||||
- Mirror regeneration of one project overview: under 5 seconds
|
||||
for a project with up to 1000 entities
|
||||
- Conflict detector at write time: adds no more than 50ms p99
|
||||
to a write that doesn't actually produce a conflict
|
||||
|
||||
These bounds are not tested by automated benchmarks in V1 (that
|
||||
would be over-engineering). They are sanity-checked by the
|
||||
developer running the operations against the test project.
|
||||
|
||||
### O-4: No new manual ops burden
|
||||
|
||||
V1 should not introduce any new "you have to remember to run X
|
||||
every day" requirement. Specifically:
|
||||
|
||||
- Mirror regeneration is automatic (debounced async + daily
|
||||
refresh), no manual cron entry needed
|
||||
- Conflict detection is automatic at write time, no manual sweep
|
||||
needed in V1 (the nightly sweep is V2)
|
||||
- Backup retention cleanup is **still** an open follow-up from
|
||||
the operational baseline; V1 does not block on it
|
||||
|
||||
### O-5: No regressions in Phase 9 reflection loop
|
||||
|
||||
The capture, reinforcement, and extraction loop from Phase 9
|
||||
A/B/C must continue to work end to end with the engineering
|
||||
layer in place. Specifically:
|
||||
|
||||
- Memories whose types are NOT in the engineering layer
|
||||
(identity, preference, episodic) keep working exactly as
|
||||
before
|
||||
- Memories whose types ARE in the engineering layer (project,
|
||||
knowledge, adaptation) can still be created hand or by
|
||||
extraction; the deprecation rule from `memory-vs-entities.md`
|
||||
("no new writes after V1 ships") is implemented as a
|
||||
configurable warning, not a hard block, so existing
|
||||
workflows aren't disrupted
|
||||
|
||||
## Category 4 — Documentation acceptance
|
||||
|
||||
### D-1: Per-entity-type spec docs
|
||||
|
||||
Each of the 12 V1 entity types has a short spec doc under
|
||||
`docs/architecture/entities/` covering:
|
||||
|
||||
- the entity's purpose
|
||||
- its required and optional fields
|
||||
- its lifecycle quirks (if any beyond the standard
|
||||
candidate/active/superseded/invalid)
|
||||
- which queries it appears in (cross-reference to the catalog)
|
||||
- which relationship types reference it
|
||||
|
||||
These docs can be terse — a page each, mostly bullet lists.
|
||||
Their purpose is to make the entity model legible to a future
|
||||
maintainer, not to be reference manuals.
|
||||
|
||||
### D-2: KB-CAD and KB-FEM export schema docs
|
||||
|
||||
`docs/architecture/kb-cad-export-schema.md` and
|
||||
`docs/architecture/kb-fem-export-schema.md` are written and
|
||||
match the implemented validators.
|
||||
|
||||
### D-3: V1 release notes
|
||||
|
||||
A `docs/v1-release-notes.md` summarizes:
|
||||
|
||||
- What V1 added (entities, relationships, conflicts, mirror,
|
||||
ingest endpoints)
|
||||
- What V1 deferred (auto-promotion, BOM/cost/manufacturing
|
||||
entities, NX direct integration, cross-project rollups)
|
||||
- The migration story for existing memories (graduation flow)
|
||||
- Known limitations and the V2 roadmap pointers
|
||||
|
||||
### D-4: master-plan-status.md and current-state.md updated
|
||||
|
||||
Both top-level status docs reflect V1's completion:
|
||||
|
||||
- Phase 6 (AtoDrive) and the engineering layer are explicitly
|
||||
marked as separate tracks
|
||||
- The engineering planning sprint section is marked complete
|
||||
- Phase 9 stays at "baseline complete" (V1 doesn't change Phase 9)
|
||||
- The engineering layer V1 is added as its own line item
|
||||
|
||||
## What V1 explicitly does NOT need to do
|
||||
|
||||
To prevent scope creep, here is the negative list. None of the
|
||||
following are V1 acceptance criteria:
|
||||
|
||||
- **No LLM extractor.** The Phase 9 C rule-based extractor is
|
||||
the entity extractor for V1 too, just with new rules added for
|
||||
entity types.
|
||||
- **No auto-promotion of candidates.** Per `promotion-rules.md`.
|
||||
- **No write-back to KB-CAD or KB-FEM.** Per
|
||||
`tool-handoff-boundaries.md`.
|
||||
- **No multi-user / per-reviewer auth.** Single-user assumed.
|
||||
- **No real-time UI.** API + Mirror markdown is the V1 surface.
|
||||
A web UI is V2+.
|
||||
- **No cross-project rollups.** Per `human-mirror-rules.md`.
|
||||
- **No time-travel queries** (Q-015 stays v1-stretch).
|
||||
- **No nightly conflict sweep.** Synchronous detection only in V1.
|
||||
- **No incremental Chroma snapshots.** The current full-copy
|
||||
approach in `backup-restore-procedure.md` is fine for V1.
|
||||
- **No retention cleanup script.** Still an open follow-up.
|
||||
- **No backup encryption.** Still an open follow-up.
|
||||
- **No off-Dalidou backup target.** Still an open follow-up.
|
||||
|
||||
## How to use this document during implementation
|
||||
|
||||
When the implementation sprint begins:
|
||||
|
||||
1. Read this doc once, top to bottom
|
||||
2. Pick the test project (probably p05-interferometer because
|
||||
the optical/structural domain has the cleanest entity model)
|
||||
3. For each section, write the test or the implementation, in
|
||||
roughly the order: F-1 → F-2 → F-3 → F-4 → F-5 → F-6 → F-7 → F-8
|
||||
4. Each acceptance criterion's test should be written **before
|
||||
or alongside** the implementation, not after
|
||||
5. Run the full test suite at every commit
|
||||
6. When every box is checked, write D-3 (release notes), update
|
||||
D-4 (status docs), and call V1 done
|
||||
|
||||
The implementation sprint should not touch anything outside the
|
||||
scope listed here. If a desire arises to add something not in
|
||||
this doc, that's a V2 conversation, not a V1 expansion.
|
||||
|
||||
## Anticipated friction points
|
||||
|
||||
These are the things I expect will be hard during implementation:
|
||||
|
||||
1. **The graduation flow (F-7)** is the most cross-cutting
|
||||
change because it touches the existing memory module.
|
||||
Worth doing it last so the memory module is stable for
|
||||
all the V1 entity work first.
|
||||
2. **The Mirror's deterministic-output requirement (Q-5)** will
|
||||
bite if the implementer iterates over Python dicts without
|
||||
sorting. Plan to use `sorted()` literally everywhere.
|
||||
3. **Conflict detection (F-5)** has subtle correctness traps:
|
||||
the slot key extraction must be stable, the dedup-of-existing-conflicts
|
||||
logic must be right, and the synchronous detector must not
|
||||
slow writes meaningfully (Q-3 / O-3 cover this, but watch).
|
||||
4. **Provenance backfill** for entities that come from the
|
||||
existing memory layer via graduation (F-7) is the trickiest
|
||||
part: the original memory may not have had a strict
|
||||
`source_chunk_id`, in which case the graduated entity also
|
||||
doesn't have one. The implementation needs an "orphan
|
||||
provenance" allowance for graduated entities, with a
|
||||
warning surfaced in the Mirror.
|
||||
|
||||
These aren't blockers, just the parts of the V1 spec I'd
|
||||
attack with extra care.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- Engineering V1 is done when every box in this doc is checked
|
||||
against one chosen active project
|
||||
- Functional: 8 criteria covering entities, queries, ingest,
|
||||
review queue, conflicts, mirror, graduation, provenance
|
||||
- Quality: 6 criteria covering tests, golden files, killer
|
||||
correctness, trust enforcement
|
||||
- Operational: 5 criteria covering migration safety, backup
|
||||
drill, performance bounds, no new manual ops, Phase 9 not
|
||||
regressed
|
||||
- Documentation: 4 criteria covering entity specs, KB schema
|
||||
docs, release notes, top-level status updates
|
||||
- Negative list: a clear set of things V1 deliberately does
|
||||
NOT need to do, to prevent scope creep
|
||||
- The implementation sprint follows this doc as a checklist
|
||||
384
docs/architecture/human-mirror-rules.md
Normal file
384
docs/architecture/human-mirror-rules.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# Human Mirror Rules (Layer 3 → derived markdown views)
|
||||
|
||||
## Why this document exists
|
||||
|
||||
The engineering layer V1 stores facts as typed entities and
|
||||
relationships in a SQL database. That representation is excellent
|
||||
for queries, conflict detection, and automated reasoning, but
|
||||
it's terrible for the human reading experience. People want to
|
||||
read prose, not crawl JSON.
|
||||
|
||||
The Human Mirror is the layer that turns the typed entity store
|
||||
into human-readable markdown pages. It's strictly a derived view —
|
||||
nothing in the Human Mirror is canonical, every page is regenerated
|
||||
from current entity state on demand.
|
||||
|
||||
This document defines:
|
||||
|
||||
- what the Human Mirror generates
|
||||
- when it regenerates
|
||||
- how the human edits things they see in the Mirror
|
||||
- how the canonical-vs-derived rule is enforced (so editing the
|
||||
derived markdown can't silently corrupt the entity store)
|
||||
|
||||
## The non-negotiable rule
|
||||
|
||||
> **The Human Mirror is read-only from the human's perspective.**
|
||||
>
|
||||
> If the human wants to change a fact they see in the Mirror, they
|
||||
> change it in the canonical home (per `representation-authority.md`),
|
||||
> NOT in the Mirror page. The next regeneration picks up the change.
|
||||
|
||||
This rule is what makes the whole derived-view approach safe. If
|
||||
the human is allowed to edit Mirror pages directly, the
|
||||
canonical-vs-derived split breaks and the Mirror becomes a second
|
||||
source of truth that disagrees with the entity store.
|
||||
|
||||
The technical enforcement is that every Mirror page carries a
|
||||
header banner that says "this file is generated from AtoCore
|
||||
entity state, do not edit", and the file is regenerated from the
|
||||
entity store on every change to its underlying entities. Manual
|
||||
edits will be silently overwritten on the next regeneration.
|
||||
|
||||
## What the Mirror generates in V1
|
||||
|
||||
Three template families, each producing one or more pages per
|
||||
project:
|
||||
|
||||
### 1. Project Overview
|
||||
|
||||
One page per registered project. Renders:
|
||||
|
||||
- Project header (id, aliases, description)
|
||||
- Subsystem tree (from Q-001 / Q-004 in the query catalog)
|
||||
- Active Decisions affecting this project (Q-008, ordered by date)
|
||||
- Open Requirements with coverage status (Q-005, Q-006)
|
||||
- Open ValidationClaims with support status (Q-010, Q-011)
|
||||
- Currently flagged conflicts (from the conflict model)
|
||||
- Recent changes (Q-013) — last 14 days
|
||||
|
||||
This is the most important Mirror page. It's the page someone
|
||||
opens when they want to know "what's the state of this project
|
||||
right now". It deliberately mirrors what `current-state.md` does
|
||||
for AtoCore itself but generated entirely from typed state.
|
||||
|
||||
### 2. Decision Log
|
||||
|
||||
One page per project. Renders:
|
||||
|
||||
- All active Decisions in chronological order (newest first)
|
||||
- Each Decision shows: id, what was decided, when, the affected
|
||||
Subsystem/Component, the supporting evidence (Q-014, Q-017)
|
||||
- Superseded Decisions appear as collapsed "history" entries
|
||||
with a forward link to whatever superseded them
|
||||
- Conflicting Decisions get a "⚠ disputed" marker
|
||||
|
||||
This is the human-readable form of the engineering query catalog's
|
||||
Q-014 query.
|
||||
|
||||
### 3. Subsystem Detail
|
||||
|
||||
One page per Subsystem (so a few per project). Renders:
|
||||
|
||||
- Subsystem header
|
||||
- Components contained in this subsystem (Q-001)
|
||||
- Interfaces this subsystem has (Q-003)
|
||||
- Constraints applying to it (Q-007)
|
||||
- Decisions affecting it (Q-008)
|
||||
- Validation status: which Requirements are satisfied,
|
||||
which are open (Q-005, Q-006)
|
||||
- Change history within this subsystem (Q-013 scoped)
|
||||
|
||||
Subsystem detail pages are what someone reads when they're
|
||||
working on a specific part of the system and want everything
|
||||
relevant in one place.
|
||||
|
||||
## What the Mirror does NOT generate in V1
|
||||
|
||||
Intentionally excluded so the V1 implementation stays scoped:
|
||||
|
||||
- **Per-component detail pages.** Components are listed in
|
||||
Subsystem pages but don't get their own pages. Reduces page
|
||||
count from hundreds to dozens.
|
||||
- **Per-Decision detail pages.** Decisions appear inline in
|
||||
Project Overview and Decision Log; their full text plus
|
||||
evidence chain is shown there, not on a separate page.
|
||||
- **Cross-project rollup pages.** No "all projects at a glance"
|
||||
page in V1. Each project is its own report.
|
||||
- **Time-series / historical pages.** The Mirror is always
|
||||
"current state". History is accessible via Decision Log and
|
||||
superseded chains, but no "what was true on date X" page exists
|
||||
in V1 (Q-015 is v1-stretch in the query catalog for the same
|
||||
reason).
|
||||
- **Diff pages between two timestamps.** Same reasoning.
|
||||
- **Render of the conflict queue itself.** Conflicts appear
|
||||
inline in the relevant Mirror pages with the "⚠ disputed"
|
||||
marker and a link to `/conflicts/{id}`, but there's no
|
||||
Mirror page that lists all conflicts. Use `GET /conflicts`.
|
||||
- **Per-memory pages.** Memories are not engineering entities;
|
||||
they appear in context packs and the review queue, not in the
|
||||
Human Mirror.
|
||||
|
||||
## Where Mirror pages live
|
||||
|
||||
Two options were considered. The chosen V1 path is option B:
|
||||
|
||||
**Option A — write Mirror pages back into the source vault.**
|
||||
Generate `/srv/storage/atocore/sources/vault/mirror/p05/overview.md`
|
||||
so the human reads them in their normal Obsidian / markdown
|
||||
viewer. **Rejected** because writing into the source vault
|
||||
violates the "sources are read-only" rule from
|
||||
`tool-handoff-boundaries.md` and the operating model.
|
||||
|
||||
**Option B (chosen) — write Mirror pages into a dedicated AtoCore
|
||||
output dir, served via the API.** Generate under
|
||||
`/srv/storage/atocore/data/mirror/p05/overview.md`. The human
|
||||
reads them via:
|
||||
|
||||
- the API endpoints `GET /mirror/{project}/overview`,
|
||||
`GET /mirror/{project}/decisions`,
|
||||
`GET /mirror/{project}/subsystems/{subsystem}` (all return
|
||||
rendered markdown as text/markdown)
|
||||
- a future "Mirror viewer" in the Claude Code slash command
|
||||
`/atocore-mirror <project>` that fetches the rendered markdown
|
||||
and displays it inline
|
||||
- direct file access on Dalidou for power users:
|
||||
`cat /srv/storage/atocore/data/mirror/p05/overview.md`
|
||||
|
||||
The dedicated dir keeps the Mirror clearly separated from the
|
||||
canonical sources and makes regeneration safe (it's just a
|
||||
directory wipe + write).
|
||||
|
||||
## When the Mirror regenerates
|
||||
|
||||
Three triggers, in order from cheapest to most expensive:
|
||||
|
||||
### 1. On explicit human request
|
||||
|
||||
```
|
||||
POST /mirror/{project}/regenerate
|
||||
```
|
||||
|
||||
Returns the timestamp of the regeneration and the list of files
|
||||
written. This is the path the human takes when they've just
|
||||
curated something into project_state and want to see the Mirror
|
||||
reflect it immediately.
|
||||
|
||||
### 2. On entity write (debounced, async, per project)
|
||||
|
||||
When any entity in a project changes status (candidate → active,
|
||||
active → superseded), a regeneration of that project's Mirror is
|
||||
queued. The queue is debounced — multiple writes within a 30-second
|
||||
window only trigger one regeneration. This keeps the Mirror
|
||||
"close to current" without generating a Mirror update on every
|
||||
single API call.
|
||||
|
||||
The implementation is a simple dict of "next regeneration time"
|
||||
per project, checked by a background task. No cron, no message
|
||||
queue, no Celery. Just a `dict[str, datetime]` and a thread.
|
||||
|
||||
### 3. On scheduled refresh (daily)
|
||||
|
||||
Once per day at a quiet hour, every project's Mirror regenerates
|
||||
unconditionally. This catches any state drift from manual
|
||||
project_state edits that bypassed the entity write hooks, and
|
||||
provides a baseline guarantee that the Mirror is at most 24
|
||||
hours stale.
|
||||
|
||||
The schedule runs from the same machinery as the future backup
|
||||
retention job, so we get one cron-equivalent system to maintain
|
||||
instead of two.
|
||||
|
||||
## What if regeneration fails
|
||||
|
||||
The Mirror has to be resilient. If regeneration fails for a
|
||||
project (e.g. a query catalog query crashes, a template rendering
|
||||
error), the existing Mirror files are **not** deleted. The
|
||||
existing files stay in place (showing the last successful state)
|
||||
and a regeneration error is recorded in:
|
||||
|
||||
- the API response if the trigger was explicit
|
||||
- a log entry at warning level for the async path
|
||||
- a `mirror_regeneration_failures` table for the daily refresh
|
||||
|
||||
This means the human can always read the Mirror, even if the
|
||||
last 5 minutes of changes haven't made it in yet. Stale is
|
||||
better than blank.
|
||||
|
||||
## How the human curates "around" the Mirror
|
||||
|
||||
The Mirror reflects the current entity state. If the human
|
||||
doesn't like what they see, the right edits go into one of:
|
||||
|
||||
| What you want to change | Where you change it |
|
||||
|---|---|
|
||||
| A Decision's text | `PUT /entities/Decision/{id}` (or `PUT /memory/{id}` if it's still memory-layer) |
|
||||
| A Decision's status (active → superseded) | `POST /entities/Decision/{id}/supersede` (V1 entity API) |
|
||||
| Whether a Component "satisfies" a Requirement | edit the relationship directly via the entity API (V1) |
|
||||
| The current trusted next focus shown on the Project Overview | `POST /project/state` with `category=status, key=next_focus` |
|
||||
| A typo in a generated heading or label | edit the **template**, not the rendered file. Templates live in `templates/mirror/` (V1 implementation) |
|
||||
| Source of a fact ("this came from KB-CAD on day X") | not editable by hand — it's automatically populated from provenance |
|
||||
|
||||
The rule is consistent: edit the canonical home, regenerate (or
|
||||
let the auto-trigger fire), see the change reflected in the
|
||||
Mirror.
|
||||
|
||||
## Templates
|
||||
|
||||
The Mirror uses Jinja2-style templates checked into the repo
|
||||
under `templates/mirror/`. Each template is a markdown file with
|
||||
placeholders that the renderer fills from query catalog results.
|
||||
|
||||
Template list for V1:
|
||||
|
||||
- `templates/mirror/project-overview.md.j2`
|
||||
- `templates/mirror/decision-log.md.j2`
|
||||
- `templates/mirror/subsystem-detail.md.j2`
|
||||
|
||||
Editing a template is a code change, reviewed via normal git PRs.
|
||||
The templates are deliberately small and readable so the human
|
||||
can tweak the output format without touching renderer code.
|
||||
|
||||
The renderer is a thin module:
|
||||
|
||||
```python
|
||||
# src/atocore/mirror/renderer.py (V1, not yet implemented)
|
||||
|
||||
def render_project_overview(project: str) -> str:
|
||||
"""Generate the project overview markdown for one project."""
|
||||
facts = collect_project_overview_facts(project)
|
||||
template = load_template("project-overview.md.j2")
|
||||
return template.render(**facts)
|
||||
```
|
||||
|
||||
## The "do not edit" header
|
||||
|
||||
Every generated Mirror file starts with a fixed banner:
|
||||
|
||||
```markdown
|
||||
<!--
|
||||
This file is generated by AtoCore from current entity state.
|
||||
DO NOT EDIT — manual changes will be silently overwritten on
|
||||
the next regeneration.
|
||||
Edit the canonical home instead. See:
|
||||
https://docs.atocore.../representation-authority.md
|
||||
Regenerated: 2026-04-07T12:34:56Z
|
||||
Source entities: <commit-like checksum of input data>
|
||||
-->
|
||||
```
|
||||
|
||||
The checksum at the end lets the renderer skip work when nothing
|
||||
relevant has changed since the last regeneration. If the inputs
|
||||
match the previous run's checksum, the existing file is left
|
||||
untouched.
|
||||
|
||||
## Conflicts in the Mirror
|
||||
|
||||
Per the conflict model, any open conflict on a fact that appears
|
||||
in the Mirror gets a visible disputed marker:
|
||||
|
||||
```markdown
|
||||
- Lateral support material: **GF-PTFE** ⚠ disputed
|
||||
- The KB-CAD import on 2026-04-07 reported PEEK; conflict #c-039.
|
||||
```
|
||||
|
||||
The disputed marker is a hyperlink (in renderer terms; the markdown
|
||||
output is a relative link) to the conflict detail page in the API
|
||||
or to the conflict id for direct lookup. The reviewer follows the
|
||||
link, resolves the conflict via `POST /conflicts/{id}/resolve`,
|
||||
and on the next regeneration the marker disappears.
|
||||
|
||||
## Project-state overrides in the Mirror
|
||||
|
||||
When a Mirror page would show a value derived from entities, but
|
||||
project_state has an override on the same key, **the Mirror shows
|
||||
the project_state value** with a small annotation noting the
|
||||
override:
|
||||
|
||||
```markdown
|
||||
- Next focus: **Wave 2 trusted-operational ingestion** (curated)
|
||||
```
|
||||
|
||||
The `(curated)` annotation tells the reader "this is from the
|
||||
trusted-state Layer 3, not from extracted entities". This makes
|
||||
the trust hierarchy visible in the human reading experience.
|
||||
|
||||
## The "Mirror diff" workflow (post-V1, but designed for)
|
||||
|
||||
A common workflow after V1 ships will be:
|
||||
|
||||
1. Reviewer has curated some new entities
|
||||
2. They want to see "what changed in the Mirror as a result"
|
||||
3. They want to share that diff with someone else as evidence
|
||||
|
||||
To support this, the Mirror generator writes its output
|
||||
deterministically (sorted iteration, stable timestamp formatting)
|
||||
so a `git diff` between two regenerated states is meaningful.
|
||||
|
||||
V1 doesn't add an explicit "diff between two Mirror snapshots"
|
||||
endpoint — that's deferred. But the deterministic-output
|
||||
property is a V1 requirement so future diffing works without
|
||||
re-renderer-design work.
|
||||
|
||||
## What the Mirror enables
|
||||
|
||||
With the Mirror in place:
|
||||
|
||||
- **OpenClaw can read project state in human form.** The
|
||||
read-only AtoCore helper skill on the T420 already calls
|
||||
`/context/build`; in V1 it gains the option to call
|
||||
`/mirror/{project}/overview` to get a fully-rendered markdown
|
||||
page instead of just retrieved chunks. This is much faster
|
||||
than crawling individual entities for general questions.
|
||||
- **The human gets a daily-readable artifact.** Every morning,
|
||||
Antoine can `cat /srv/storage/atocore/data/mirror/p05/overview.md`
|
||||
and see the current state of p05 in his preferred reading
|
||||
format. No API calls, no JSON parsing.
|
||||
- **Cross-collaborator sharing.** If you ever want to send
|
||||
someone a project overview without giving them AtoCore access,
|
||||
the Mirror file is a self-contained markdown document they can
|
||||
read in any markdown viewer.
|
||||
- **Claude Code integration.** A future
|
||||
`/atocore-mirror <project>` slash command renders the Mirror
|
||||
inline, complementing the existing `/atocore-context` command
|
||||
with a human-readable view of "what does AtoCore think about
|
||||
this project right now".
|
||||
|
||||
## Open questions for V1 implementation
|
||||
|
||||
1. **What's the regeneration debounce window?** 30 seconds is the
|
||||
starting value but should be tuned with real usage.
|
||||
2. **Does the daily refresh need a separate trigger mechanism, or
|
||||
is it just a long-period entry in the same in-process scheduler
|
||||
that handles the debounced async refreshes?** Probably the
|
||||
latter — keep it simple.
|
||||
3. **How are templates tested?** Likely a small set of fixture
|
||||
project states + golden output files, with a single test that
|
||||
asserts `render(fixture) == golden`. Updating golden files is
|
||||
a normal part of template work.
|
||||
4. **Are Mirror pages discoverable via a directory listing
|
||||
endpoint?** `GET /mirror/{project}` returns the list of
|
||||
available pages for that project. Probably yes; cheap to add.
|
||||
5. **How does the Mirror handle a project that has zero entities
|
||||
yet?** Render an empty-state page that says "no curated facts
|
||||
yet — add some via /memory or /entities/Decision". Better than
|
||||
a blank file.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- The Human Mirror generates 3 template families per project
|
||||
(Overview, Decision Log, Subsystem Detail) from current entity
|
||||
state
|
||||
- It's strictly read-only from the human's perspective; edits go
|
||||
to the canonical home and the Mirror picks them up on
|
||||
regeneration
|
||||
- Three regeneration triggers: explicit POST, debounced
|
||||
async-on-write, daily scheduled refresh
|
||||
- Mirror files live in `/srv/storage/atocore/data/mirror/`
|
||||
(NOT in the source vault — sources stay read-only)
|
||||
- Conflicts and project_state overrides are visible inline in
|
||||
the rendered markdown so the trust hierarchy shows through
|
||||
- Templates are checked into the repo and edited via PR; the
|
||||
rendered files are derived and never canonical
|
||||
- Deterministic output is a V1 requirement so future diffing
|
||||
works without rework
|
||||
206
docs/architecture/knowledge-architecture.md
Normal file
206
docs/architecture/knowledge-architecture.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# AtoCore Knowledge Architecture
|
||||
|
||||
## The Problem
|
||||
|
||||
Engineering work produces two kinds of knowledge simultaneously:
|
||||
|
||||
1. **Applied knowledge** — specific to the project being worked on
|
||||
("the p04 support pad layout is driven by CTE gradient analysis")
|
||||
2. **Domain knowledge** — generalizable insight earned through that work
|
||||
("Zerodur CTE gradient dominates WFE at fast focal ratios")
|
||||
|
||||
A system that only stores applied knowledge loses the general insight.
|
||||
A system that mixes them pollutes project context with cross-project
|
||||
noise. AtoCore needs both — separated, but both growing organically
|
||||
from the same conversations.
|
||||
|
||||
## The Quality Bar
|
||||
|
||||
**AtoCore stores earned insight, not information.**
|
||||
|
||||
The test: "Would a competent engineer need experience to know this,
|
||||
or could they find it in 30 seconds?"
|
||||
|
||||
| Store | Don't store |
|
||||
|-------|-------------|
|
||||
| "Preston removal model breaks down below 5N because the contact assumption fails" | "Preston's equation relates removal rate to pressure and velocity" |
|
||||
| "m=1 (coma) is NOT correctable by force modulation (score 0.09)" | "Zernike polynomials describe wavefront aberrations" |
|
||||
| "At F/1.2, CTE gradient costs ~3nm WFE and drives pad placement" | "Zerodur CTE is 0.05 ppm/K" |
|
||||
| "Quilting limit for 16-inch tool is 234N" | "Quilting is a mid-spatial-frequency artifact in polishing" |
|
||||
|
||||
The bar is enforced in the LLM extraction system prompt
|
||||
(`src/atocore/memory/extractor_llm.py`) and the auto-triage prompt
|
||||
(`scripts/auto_triage.py`). Both explicitly list examples of what
|
||||
qualifies and what doesn't.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Five-tier context assembly
|
||||
|
||||
When AtoCore builds a context pack for any LLM query, it assembles
|
||||
five tiers in strict trust order:
|
||||
|
||||
```
|
||||
Tier 1: Trusted Project State [project-specific, highest trust]
|
||||
Curated key-value entries from the project state API.
|
||||
Example: "decision/vendor_path: Twyman-Green preferred, 4D
|
||||
technical lead but cost-challenged"
|
||||
|
||||
Tier 2: Identity / Preferences [global, always included]
|
||||
Who the user is and how they work.
|
||||
Example: "Antoine Letarte, mechanical/optical engineer at
|
||||
Atomaste" / "No API keys — uses OAuth exclusively"
|
||||
|
||||
Tier 3: Project Memories [project-specific]
|
||||
Reinforced memories from the reflection loop, scoped to the
|
||||
queried project. Example: "Firmware interface contract is
|
||||
invariant: controller-job.v1 in, run-log.v1 out"
|
||||
|
||||
Tier 4: Domain Knowledge [cross-project]
|
||||
Earned engineering insight with project="" and a domain tag.
|
||||
Surfaces in ALL project packs when query-relevant.
|
||||
Example: "[materials] Zerodur CTE gradient dominates WFE at
|
||||
fast focal ratios — costs ~3nm at F/1.2"
|
||||
|
||||
Tier 5: Retrieved Chunks [project-boosted, lowest trust]
|
||||
Vector-similarity search over the ingested document corpus.
|
||||
Project-hinted but not filtered — cross-project docs can
|
||||
appear at lower rank.
|
||||
```
|
||||
|
||||
### Budget allocation (at default 3000 chars)
|
||||
|
||||
| Tier | Budget ratio | Approx chars | Entries |
|
||||
|------|-------------|-------------|---------|
|
||||
| Project State | 20% | 600 | all curated entries |
|
||||
| Identity/Preferences | 5% | 150 | 1 memory |
|
||||
| Project Memories | 25% | 750 | 2-3 memories |
|
||||
| Domain Knowledge | 10% | 300 | 1-2 memories |
|
||||
| Retrieved Chunks | 40% | 1200 | 2-4 chunks |
|
||||
|
||||
Trim order when budget is tight: chunks first, then domain knowledge,
|
||||
then project memories, then identity, then project state last.
|
||||
|
||||
### Knowledge domains
|
||||
|
||||
The LLM extractor tags domain knowledge with one of these domains:
|
||||
|
||||
| Domain | What qualifies |
|
||||
|--------|---------------|
|
||||
| `physics` | Optical physics, wave propagation, diffraction, thermal effects |
|
||||
| `materials` | Material properties in context, CTE behavior, stress limits |
|
||||
| `optics` | Lens/mirror design, aberration analysis, metrology techniques |
|
||||
| `mechanics` | Structural FEA insights, support system design, kinematics |
|
||||
| `manufacturing` | Polishing, grinding, machining, process control |
|
||||
| `metrology` | Measurement systems, interferometry, calibration techniques |
|
||||
| `controls` | PID tuning, force control, servo systems, real-time constraints |
|
||||
| `software` | Architecture patterns, testing strategies, deployment insights |
|
||||
| `math` | Numerical methods, optimization, statistical analysis |
|
||||
| `finance` | Cost modeling, procurement strategy, budget optimization |
|
||||
|
||||
New domains can be added by updating the system prompt in
|
||||
`extractor_llm.py` and `batch_llm_extract_live.py`.
|
||||
|
||||
### How domain knowledge is stored
|
||||
|
||||
Domain tags are embedded as a prefix in the memory content:
|
||||
|
||||
```
|
||||
memory_type: knowledge
|
||||
project: "" ← empty = cross-project
|
||||
content: "[materials] Zerodur CTE gradient dominates WFE at F/1.2"
|
||||
```
|
||||
|
||||
The `[domain]` prefix is a lightweight encoding that avoids a schema
|
||||
migration. The context builder's query-relevance ranking matches on
|
||||
domain terms naturally (a query about "materials" or "CTE" will rank
|
||||
a `[materials]` memory higher). A future migration can parse the
|
||||
prefix into a proper `domain` column.
|
||||
|
||||
## How knowledge flows
|
||||
|
||||
### Capture → Extract → Triage → Surface
|
||||
|
||||
```
|
||||
1. CAPTURE
|
||||
Claude Code (Stop hook) or OpenClaw (plugin)
|
||||
→ POST /interactions with reinforce=true
|
||||
→ Interaction stored on Dalidou
|
||||
|
||||
2. EXTRACT (nightly cron, 03:00 UTC)
|
||||
batch_llm_extract_live.py runs claude -p sonnet
|
||||
→ For each interaction, the LLM decides:
|
||||
- Is this project-specific? → candidate with project=X
|
||||
- Is this generalizable insight? → candidate with domain=Y, project=""
|
||||
- Is it both? → TWO candidates emitted
|
||||
- Is it common knowledge? → skip (quality bar)
|
||||
→ Candidates persisted as status=candidate
|
||||
|
||||
3. TRIAGE (nightly, immediately after extraction)
|
||||
auto_triage.py runs claude -p sonnet
|
||||
→ Each candidate classified: promote / reject / needs_human
|
||||
→ Auto-promote at confidence ≥ 0.8 + no duplicate
|
||||
→ Auto-reject stale snapshots, duplicates, common knowledge
|
||||
→ Only needs_human reaches the operator
|
||||
|
||||
4. SURFACE (every context/build query)
|
||||
→ Project-specific memories appear in Tier 3
|
||||
→ Domain knowledge appears in Tier 4 (regardless of project)
|
||||
→ Both are query-ranked by overlap-density
|
||||
```
|
||||
|
||||
### Example: knowledge earned on p04 surfaces on p06
|
||||
|
||||
Working on p04-gigabit, you discover that Zerodur CTE gradient is
|
||||
the dominant WFE contributor at fast focal ratios. The extraction
|
||||
produces:
|
||||
|
||||
```json
|
||||
[
|
||||
{"type": "project", "content": "CTE gradient analysis drove the
|
||||
M1 support pad layout — 2nd largest WFE contributor after gravity",
|
||||
"project": "p04-gigabit", "domain": "", "confidence": 0.6},
|
||||
|
||||
{"type": "knowledge", "content": "Zerodur CTE gradient dominates
|
||||
WFE contribution at fast focal ratios (F/1.2 = ~3nm)",
|
||||
"project": "", "domain": "materials", "confidence": 0.6}
|
||||
]
|
||||
```
|
||||
|
||||
Two weeks later, working on p06-polisher (which also uses Zerodur):
|
||||
|
||||
```
|
||||
Query: "thermal effects on polishing accuracy"
|
||||
Project: p06-polisher
|
||||
|
||||
Tier 3 (Project Memories):
|
||||
[project] Calibration loop adjusts Preston kp from surface measurements...
|
||||
|
||||
Tier 4 (Domain Knowledge):
|
||||
[materials] Zerodur CTE gradient dominates WFE contribution at fast
|
||||
focal ratios — THIS CAME FROM P04 WORK
|
||||
```
|
||||
|
||||
The insight crosses over without any manual curation.
|
||||
|
||||
## Future directions
|
||||
|
||||
### Personal knowledge branch
|
||||
The same architecture supports personal domains (health, finance,
|
||||
personal) by adding new domain tags and a trust boundary so
|
||||
Atomaste project data never leaks into personal packs. The domain
|
||||
system is domain-agnostic — it doesn't care whether the domain is
|
||||
"optics" or "nutrition".
|
||||
|
||||
### Multi-model extraction
|
||||
Different models can specialize: sonnet for extraction, opus or
|
||||
Gemini for triage review. Independent validation reduces correlated
|
||||
blind spots on what qualifies as "earned insight" vs "common
|
||||
knowledge."
|
||||
|
||||
### Reinforcement-based domain promotion
|
||||
A domain-knowledge memory that gets reinforced across multiple
|
||||
projects (its content echoed in p04, p05, and p06 responses)
|
||||
accumulates confidence faster than a project-specific memory.
|
||||
High-confidence domain memories could auto-promote to a "verified
|
||||
knowledge" tier above regular domain knowledge.
|
||||
333
docs/architecture/llm-client-integration.md
Normal file
333
docs/architecture/llm-client-integration.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# LLM Client Integration (the layering)
|
||||
|
||||
## Why this document exists
|
||||
|
||||
AtoCore must be reachable from many different LLM client contexts:
|
||||
|
||||
- **OpenClaw** on the T420 (already integrated via the read-only
|
||||
helper skill at `/home/papa/clawd/skills/atocore-context/`)
|
||||
- **Claude Code** on the laptop (via the slash command shipped in
|
||||
this repo at `.claude/commands/atocore-context.md`)
|
||||
- **Codex** sessions (future)
|
||||
- **Direct API consumers** — scripts, Python code, ad-hoc curl
|
||||
- **The eventual MCP server** when it's worth building
|
||||
|
||||
Without an explicit layering rule, every new client tends to
|
||||
reimplement the same routing logic (project detection, context
|
||||
build, retrieval audit, project-state inspection) in slightly
|
||||
different ways. That is exactly what almost happened in the first
|
||||
draft of the Claude Code slash command, which started as a curl +
|
||||
jq script that duplicated capabilities the existing operator client
|
||||
already had.
|
||||
|
||||
This document defines the layering so future clients don't repeat
|
||||
that mistake.
|
||||
|
||||
## The layering
|
||||
|
||||
Three layers, top to bottom:
|
||||
|
||||
```
|
||||
+----------------------------------------------------+
|
||||
| Per-agent thin frontends |
|
||||
| |
|
||||
| - Claude Code slash command |
|
||||
| (.claude/commands/atocore-context.md) |
|
||||
| - OpenClaw helper skill |
|
||||
| (/home/papa/clawd/skills/atocore-context/) |
|
||||
| - Codex skill (future) |
|
||||
| - MCP server (future) |
|
||||
+----------------------------------------------------+
|
||||
|
|
||||
| shells out to / imports
|
||||
v
|
||||
+----------------------------------------------------+
|
||||
| Shared operator client |
|
||||
| scripts/atocore_client.py |
|
||||
| |
|
||||
| - subcommands for stable AtoCore operations |
|
||||
| - fail-open on network errors |
|
||||
| - consistent JSON output across all subcommands |
|
||||
| - environment-driven configuration |
|
||||
| (ATOCORE_BASE_URL, ATOCORE_TIMEOUT_SECONDS, |
|
||||
| ATOCORE_REFRESH_TIMEOUT_SECONDS, |
|
||||
| ATOCORE_FAIL_OPEN) |
|
||||
+----------------------------------------------------+
|
||||
|
|
||||
| HTTP
|
||||
v
|
||||
+----------------------------------------------------+
|
||||
| AtoCore HTTP API |
|
||||
| src/atocore/api/routes.py |
|
||||
| |
|
||||
| - the universal interface to AtoCore |
|
||||
| - everything else above is glue |
|
||||
+----------------------------------------------------+
|
||||
```
|
||||
|
||||
## The non-negotiable rules
|
||||
|
||||
These rules are what make the layering work.
|
||||
|
||||
### Rule 1 — every per-agent frontend is a thin wrapper
|
||||
|
||||
A per-agent frontend exists to do exactly two things:
|
||||
|
||||
1. **Translate the agent platform's command/skill format** into an
|
||||
invocation of the shared client (or a small sequence of them)
|
||||
2. **Render the JSON response** into whatever shape the agent
|
||||
platform wants (markdown for Claude Code, plaintext for
|
||||
OpenClaw, MCP tool result for an MCP server, etc.)
|
||||
|
||||
Everything else — talking to AtoCore, project detection, retrieval
|
||||
audit, fail-open behavior, configuration — is the **shared
|
||||
client's** job.
|
||||
|
||||
If a per-agent frontend grows logic beyond the two responsibilities
|
||||
above, that logic is in the wrong place. It belongs in the shared
|
||||
client where every other frontend gets to use it.
|
||||
|
||||
### Rule 2 — the shared client never duplicates the API
|
||||
|
||||
The shared client is allowed to **compose** API calls (e.g.
|
||||
`auto-context` calls `detect-project` then `context-build`), but
|
||||
it never reimplements API logic. If a useful operation can't be
|
||||
expressed via the existing API endpoints, the right fix is to
|
||||
extend the API, not to embed the logic in the client.
|
||||
|
||||
This rule keeps the API as the single source of truth for what
|
||||
AtoCore can do.
|
||||
|
||||
### Rule 3 — the shared client only exposes stable operations
|
||||
|
||||
A subcommand only makes it into the shared client when:
|
||||
|
||||
- the API endpoint behind it has been exercised by at least one
|
||||
real workflow
|
||||
- the request and response shapes are unlikely to change
|
||||
- the operation is one that more than one frontend will plausibly
|
||||
want
|
||||
|
||||
This rule keeps the client surface stable so frontends don't have
|
||||
to chase changes. New endpoints land in the API first, get
|
||||
exercised in real use, and only then get a client subcommand.
|
||||
|
||||
## What's in scope for the shared client today
|
||||
|
||||
The currently shipped scope (per `scripts/atocore_client.py`):
|
||||
|
||||
### Stable operations (shipped since the client was introduced)
|
||||
|
||||
| Subcommand | Purpose | API endpoint(s) |
|
||||
|---|---|---|
|
||||
| `health` | service status, mount + source readiness | `GET /health` |
|
||||
| `sources` | enabled source roots and their existence | `GET /sources` |
|
||||
| `stats` | document/chunk/vector counts | `GET /stats` |
|
||||
| `projects` | registered projects | `GET /projects` |
|
||||
| `project-template` | starter shape for a new project | `GET /projects/template` |
|
||||
| `propose-project` | preview a registration | `POST /projects/proposal` |
|
||||
| `register-project` | persist a registration | `POST /projects/register` |
|
||||
| `update-project` | update an existing registration | `PUT /projects/{name}` |
|
||||
| `refresh-project` | re-ingest a project's roots | `POST /projects/{name}/refresh` |
|
||||
| `project-state` | list trusted state for a project | `GET /project/state/{name}` |
|
||||
| `project-state-set` | curate trusted state | `POST /project/state` |
|
||||
| `project-state-invalidate` | supersede trusted state | `DELETE /project/state` |
|
||||
| `query` | raw retrieval | `POST /query` |
|
||||
| `context-build` | full context pack | `POST /context/build` |
|
||||
| `auto-context` | detect-project then context-build | composes `/projects` + `/context/build` |
|
||||
| `detect-project` | match a prompt to a registered project | composes `/projects` + local regex |
|
||||
| `audit-query` | retrieval-quality audit with classification | composes `/query` + local labelling |
|
||||
| `debug-context` | last context pack inspection | `GET /debug/context` |
|
||||
| `ingest-sources` | ingest configured source dirs | `POST /ingest/sources` |
|
||||
|
||||
### Phase 9 reflection loop (shipped after migration safety work)
|
||||
|
||||
These were explicitly deferred in earlier versions of this doc
|
||||
pending "exercised workflow". The constraint was real — premature
|
||||
API freeze would have made it harder to iterate on the ergonomics —
|
||||
but the deferral ran into a bootstrap problem: you can't exercise
|
||||
the workflow in real Claude Code sessions without a usable client
|
||||
surface to drive it from. The fix is to ship a minimal Phase 9
|
||||
surface now and treat it as stable-but-refinable: adding new
|
||||
optional parameters is fine, renaming subcommands is not.
|
||||
|
||||
| Subcommand | Purpose | API endpoint(s) |
|
||||
|---|---|---|
|
||||
| `capture` | record one interaction round-trip | `POST /interactions` |
|
||||
| `extract` | run the rule-based extractor (preview or persist) | `POST /interactions/{id}/extract` |
|
||||
| `reinforce-interaction` | backfill reinforcement on an existing interaction | `POST /interactions/{id}/reinforce` |
|
||||
| `list-interactions` | paginated list with filters | `GET /interactions` |
|
||||
| `get-interaction` | fetch one interaction by id | `GET /interactions/{id}` |
|
||||
| `queue` | list the candidate review queue | `GET /memory?status=candidate` |
|
||||
| `promote` | move a candidate memory to active | `POST /memory/{id}/promote` |
|
||||
| `reject` | mark a candidate memory invalid | `POST /memory/{id}/reject` |
|
||||
|
||||
All 8 Phase 9 subcommands have test coverage in
|
||||
`tests/test_atocore_client.py` via mocked `request()`, including
|
||||
an end-to-end test that drives the full capture → extract → queue
|
||||
→ promote/reject cycle through the client.
|
||||
|
||||
### Coverage summary
|
||||
|
||||
That covers everything in the "stable operations" set AND the
|
||||
full Phase 9 reflection loop: project lifecycle, ingestion,
|
||||
project-state curation, retrieval, context build,
|
||||
retrieval-quality audit, health and stats inspection, interaction
|
||||
capture, candidate extraction, candidate review queue.
|
||||
|
||||
## What's intentionally NOT in scope today
|
||||
|
||||
Two families of operations remain deferred:
|
||||
|
||||
### 1. Backup and restore admin operations
|
||||
|
||||
Phase 9 Commit B shipped these endpoints:
|
||||
|
||||
- `POST /admin/backup` (with `include_chroma`)
|
||||
- `GET /admin/backup` (list)
|
||||
- `GET /admin/backup/{stamp}/validate`
|
||||
|
||||
The backup endpoints are stable, but the documented operational
|
||||
procedure (`docs/backup-restore-procedure.md`) intentionally uses
|
||||
direct curl rather than the shared client. The reason is that
|
||||
backup operations are *administrative* and benefit from being
|
||||
explicit about which instance they're targeting, with no
|
||||
fail-open behavior. The shared client's fail-open default would
|
||||
hide a real backup failure.
|
||||
|
||||
If we later decide to add backup commands to the shared client,
|
||||
they would set `ATOCORE_FAIL_OPEN=false` for the duration of the
|
||||
call so the operator gets a real error on failure rather than a
|
||||
silent fail-open envelope.
|
||||
|
||||
### 2. Engineering layer entity operations
|
||||
|
||||
The engineering layer is in planning, not implementation. When
|
||||
V1 ships per `engineering-v1-acceptance.md`, the shared client
|
||||
will gain entity, relationship, conflict, and Mirror commands.
|
||||
None of those exist as stable contracts yet, so they are not in
|
||||
the shared client today.
|
||||
|
||||
## How a new agent platform integrates
|
||||
|
||||
When a new LLM client needs AtoCore (e.g. Codex, ChatGPT custom
|
||||
GPT, a Cursor extension), the integration recipe is:
|
||||
|
||||
1. **Don't reimplement.** Don't write a new HTTP client. Use the
|
||||
shared client.
|
||||
2. **Write a thin frontend** that translates the platform's
|
||||
command/skill format into a shell call to
|
||||
`python scripts/atocore_client.py <subcommand> <args...>`.
|
||||
3. **Render the JSON response** in the platform's preferred shape.
|
||||
4. **Inherit fail-open and env-var behavior** from the shared
|
||||
client. Don't override unless the platform explicitly needs
|
||||
to (e.g. an admin tool that wants to see real errors).
|
||||
5. **If a needed capability is missing**, propose adding it to
|
||||
the shared client. If the underlying API endpoint also
|
||||
doesn't exist, propose adding it to the API first. Don't
|
||||
add the logic to your frontend.
|
||||
|
||||
The Claude Code slash command in this repo is a worked example:
|
||||
~50 lines of markdown that does argument parsing, calls the
|
||||
shared client, and renders the result. It contains zero AtoCore
|
||||
business logic of its own.
|
||||
|
||||
## How OpenClaw fits
|
||||
|
||||
OpenClaw's helper skill at `/home/papa/clawd/skills/atocore-context/`
|
||||
on the T420 currently has its own implementation of `auto-context`,
|
||||
`detect-project`, and the project lifecycle commands. It predates
|
||||
this layering doc.
|
||||
|
||||
The right long-term shape is to **refactor the OpenClaw helper to
|
||||
shell out to the shared client** instead of duplicating the
|
||||
routing logic. This isn't urgent because:
|
||||
|
||||
- OpenClaw's helper works today and is in active use
|
||||
- The duplication is on the OpenClaw side; AtoCore itself is not
|
||||
affected
|
||||
- The shared client and the OpenClaw helper are in different
|
||||
repos (AtoCore vs OpenClaw clawd), so the refactor is a
|
||||
cross-repo coordination
|
||||
|
||||
The refactor is queued as a follow-up. Until then, **the OpenClaw
|
||||
helper and the Claude Code slash command are parallel
|
||||
implementations** of the same idea. The shared client is the
|
||||
canonical backbone going forward; new clients should follow the
|
||||
new pattern even though the existing OpenClaw helper still has
|
||||
its own.
|
||||
|
||||
## How this connects to the master plan
|
||||
|
||||
| Layer | Phase home | Status |
|
||||
|---|---|---|
|
||||
| AtoCore HTTP API | Phases 0/0.5/1/2/3/5/7/9 | shipped |
|
||||
| Shared operator client (`scripts/atocore_client.py`) | implicitly Phase 8 (OpenClaw integration) infrastructure | shipped via codex/port-atocore-ops-client merge |
|
||||
| OpenClaw helper skill (T420) | Phase 8 — partial | shipped (own implementation, refactor queued) |
|
||||
| Claude Code slash command (this repo) | precursor to Phase 11 (multi-model) | shipped (refactored to use the shared client) |
|
||||
| Codex skill | Phase 11 | future |
|
||||
| MCP server | Phase 11 | future |
|
||||
| Web UI / dashboard | Phase 11+ | future |
|
||||
|
||||
The shared client is the **substrate Phase 11 will build on**.
|
||||
Every new client added in Phase 11 should be a thin frontend on
|
||||
the shared client, not a fresh reimplementation.
|
||||
|
||||
## Versioning and stability
|
||||
|
||||
The shared client's subcommand surface is **stable**. Adding new
|
||||
subcommands is non-breaking. Changing or removing existing
|
||||
subcommands is breaking and would require a coordinated update
|
||||
of every frontend that depends on them.
|
||||
|
||||
The current shared client has no explicit version constant; the
|
||||
implicit contract is "the subcommands and JSON shapes documented
|
||||
in this file". When the client surface meaningfully changes,
|
||||
add a `CLIENT_VERSION = "x.y.z"` constant to
|
||||
`scripts/atocore_client.py` and bump it per semver:
|
||||
|
||||
- patch: bug fixes, no surface change
|
||||
- minor: new subcommands or new optional fields
|
||||
- major: removed subcommands, renamed fields, changed defaults
|
||||
|
||||
## Open follow-ups
|
||||
|
||||
1. **Refactor the OpenClaw helper** to shell out to the shared
|
||||
client. Cross-repo coordination, not blocking anything in
|
||||
AtoCore itself. With the Phase 9 subcommands now in the shared
|
||||
client, the OpenClaw refactor can reuse all the reflection-loop
|
||||
work instead of duplicating it.
|
||||
2. **Real-usage validation of the Phase 9 loop**, now that the
|
||||
client surface exists. First capture → extract → review cycle
|
||||
against the live Dalidou instance, likely via the Claude Code
|
||||
slash command flow. Findings feed back into subcommand
|
||||
refinement (new optional flags are fine, renames require a
|
||||
semver bump).
|
||||
3. **Add backup admin subcommands** if and when we decide the
|
||||
shared client should be the canonical backup operator
|
||||
interface (with fail-open disabled for admin commands).
|
||||
4. **Add engineering-layer entity subcommands** as part of the
|
||||
engineering V1 implementation sprint, per
|
||||
`engineering-v1-acceptance.md`.
|
||||
5. **Tag a `CLIENT_VERSION` constant** the next time the shared
|
||||
client surface meaningfully changes. Today's surface with the
|
||||
Phase 9 loop added is the v0.2.0 baseline (v0.1.0 was the
|
||||
stable-ops-only version).
|
||||
|
||||
## TL;DR
|
||||
|
||||
- AtoCore HTTP API is the universal interface
|
||||
- `scripts/atocore_client.py` is the canonical shared Python
|
||||
backbone for stable AtoCore operations
|
||||
- Per-agent frontends (Claude Code slash command, OpenClaw
|
||||
helper, future Codex skill, future MCP server) are thin
|
||||
wrappers that shell out to the shared client
|
||||
- The shared client today covers project lifecycle, ingestion,
|
||||
retrieval, context build, project-state, retrieval audit, AND
|
||||
the full Phase 9 reflection loop (capture / extract /
|
||||
reinforce / list / queue / promote / reject)
|
||||
- Backup admin and engineering-entity commands remain deferred
|
||||
- The OpenClaw helper is currently a parallel implementation and
|
||||
the refactor to the shared client is a queued follow-up
|
||||
- New LLM clients should never reimplement HTTP calls — they
|
||||
follow the shell-out pattern documented here
|
||||
462
docs/architecture/project-identity-canonicalization.md
Normal file
462
docs/architecture/project-identity-canonicalization.md
Normal file
@@ -0,0 +1,462 @@
|
||||
# Project Identity Canonicalization
|
||||
|
||||
## Why this document exists
|
||||
|
||||
AtoCore identifies projects by name in many places: trusted state
|
||||
rows, memories, captured interactions, query/context API parameters,
|
||||
extractor candidates, future engineering entities. Without an
|
||||
explicit rule, every callsite would have to remember to canonicalize
|
||||
project names through the registry — and the recent codex review
|
||||
caught exactly the bug class that follows when one of them forgets.
|
||||
|
||||
The fix landed in `fb6298a` and works correctly today. This document
|
||||
exists to make the rule **explicit and discoverable** so the
|
||||
engineering layer V1 implementation, future entity write paths, and
|
||||
any new agent integration don't reintroduce the same fragmentation
|
||||
when nobody is looking.
|
||||
|
||||
## The contract
|
||||
|
||||
> **Every read/write that takes a project name MUST canonicalize it
|
||||
> through `resolve_project_name()` before the value crosses a service
|
||||
> boundary.**
|
||||
|
||||
The boundary is wherever a project name becomes a database row, a
|
||||
query filter, an attribute on a stored object, or a key for any
|
||||
lookup. The canonicalization happens **once**, at that boundary,
|
||||
before the underlying storage primitive is called.
|
||||
|
||||
Symbolically:
|
||||
|
||||
```
|
||||
HTTP layer (raw user input)
|
||||
↓
|
||||
service entry point
|
||||
↓
|
||||
project_name = resolve_project_name(project_name) ← ONLY canonical from this point
|
||||
↓
|
||||
storage / queries / further service calls
|
||||
```
|
||||
|
||||
The rule is intentionally simple. There's no per-call exception,
|
||||
no "trust me, the caller already canonicalized it" shortcut, no
|
||||
opt-out flag. Every service-layer entry point applies the helper
|
||||
the moment it receives a project name from outside the service.
|
||||
|
||||
## The helper
|
||||
|
||||
```python
|
||||
# src/atocore/projects/registry.py
|
||||
|
||||
def resolve_project_name(name: str | None) -> str:
|
||||
"""Canonicalize a project name through the registry.
|
||||
|
||||
Returns the canonical project_id if the input matches any
|
||||
registered project's id or alias. Returns the input unchanged
|
||||
when it's empty or not in the registry — the second case keeps
|
||||
backwards compatibility with hand-curated state, memories, and
|
||||
interactions that predate the registry, or for projects that
|
||||
are intentionally not registered.
|
||||
"""
|
||||
if not name:
|
||||
return name or ""
|
||||
project = get_registered_project(name)
|
||||
if project is not None:
|
||||
return project.project_id
|
||||
return name
|
||||
```
|
||||
|
||||
Three behaviors worth keeping in mind:
|
||||
|
||||
1. **Empty / None input → empty string output.** Callers don't have
|
||||
to pre-check; passing `""` or `None` to a query filter still
|
||||
works as "no project scope".
|
||||
2. **Registered alias → canonical project_id.** The helper does the
|
||||
case-insensitive lookup and returns the project's `id` field
|
||||
(e.g. `"p05" → "p05-interferometer"`).
|
||||
3. **Unregistered name → input unchanged.** This is the
|
||||
backwards-compatibility path. Hand-curated state, memories, or
|
||||
interactions created under a name that isn't in the registry
|
||||
keep working. The retrieval is then "best effort" — the raw
|
||||
string is used as the SQL key, which still finds the row that
|
||||
was stored under the same raw string. This path exists so the
|
||||
engineering layer V1 doesn't have to also be a data migration.
|
||||
|
||||
## Where the helper is currently called
|
||||
|
||||
As of `fb6298a`, the helper is invoked at exactly these eight
|
||||
service-layer entry points:
|
||||
|
||||
| Module | Function | What gets canonicalized |
|
||||
|---|---|---|
|
||||
| `src/atocore/context/builder.py` | `build_context` | the `project_hint` parameter, before the trusted state lookup |
|
||||
| `src/atocore/context/project_state.py` | `set_state` | `project_name`, before `ensure_project()` |
|
||||
| `src/atocore/context/project_state.py` | `get_state` | `project_name`, before the SQL lookup |
|
||||
| `src/atocore/context/project_state.py` | `invalidate_state` | `project_name`, before the SQL lookup |
|
||||
| `src/atocore/interactions/service.py` | `record_interaction` | `project`, before insert |
|
||||
| `src/atocore/interactions/service.py` | `list_interactions` | `project` filter parameter, before WHERE clause |
|
||||
| `src/atocore/memory/service.py` | `create_memory` | `project`, before insert |
|
||||
| `src/atocore/memory/service.py` | `get_memories` | `project` filter parameter, before WHERE clause |
|
||||
|
||||
Every one of those is the **first** thing the function does after
|
||||
input validation. There is no path through any of those eight
|
||||
functions where a project name reaches storage without passing
|
||||
through `resolve_project_name`.
|
||||
|
||||
## Where the helper is NOT called (and why that's correct)
|
||||
|
||||
These places intentionally do not canonicalize:
|
||||
|
||||
1. **`update_memory`'s project field.** The API does not allow
|
||||
changing a memory's project after creation, so there's no
|
||||
project to canonicalize. The function only updates `content`,
|
||||
`confidence`, and `status`.
|
||||
2. **The retriever's `_project_match_boost` substring matcher.** It
|
||||
already calls `get_registered_project` internally to expand the
|
||||
hint into the candidate set (canonical id + all aliases + last
|
||||
path segments). It accepts the raw hint by design.
|
||||
3. **`_rank_chunks`'s secondary substring boost in
|
||||
`builder.py`.** Still uses the raw hint. This is a multiplicative
|
||||
factor on top of correct retrieval, not a filter, so it cannot
|
||||
drop relevant chunks. Tracked as a future cleanup but not
|
||||
critical.
|
||||
4. **Direct SQL queries for the projects table itself** (e.g.
|
||||
`ensure_project`'s lookup). These are intentional case-insensitive
|
||||
raw lookups against the column the canonical id is stored in.
|
||||
`set_state` already canonicalized before reaching `ensure_project`,
|
||||
so the value passed is the canonical id by definition.
|
||||
5. **Hand-authored project names that aren't in the registry.**
|
||||
The helper returns those unchanged. This is the backwards-compat
|
||||
path mentioned above; it is *not* a violation of the rule, it's
|
||||
the rule applied to a name with no registry record.
|
||||
|
||||
## Why this is the trust hierarchy in action
|
||||
|
||||
The whole point of AtoCore is the trust hierarchy from the operating
|
||||
model:
|
||||
|
||||
1. Trusted Project State (Layer 3) is the most authoritative layer
|
||||
2. Memories (active) are second
|
||||
3. Source chunks (raw retrieved content) are last
|
||||
|
||||
If a caller passes the alias `p05` and Layer 3 was written under
|
||||
`p05-interferometer`, and the lookup fails to find the canonical
|
||||
row, **the trust hierarchy collapses**. The most-authoritative
|
||||
layer is silently invisible to the caller. The system would still
|
||||
return *something* — namely, lower-trust retrieved chunks — and the
|
||||
human would never know they got a degraded answer.
|
||||
|
||||
The canonicalization helper is what makes the trust hierarchy
|
||||
**dependable**. Layer 3 is supposed to win every time. To win it
|
||||
has to be findable. To be findable, the lookup key has to match
|
||||
how the row was stored. And the only way to guarantee that match
|
||||
across every entry point is to canonicalize at every boundary.
|
||||
|
||||
## Compatibility gap: legacy alias-keyed rows
|
||||
|
||||
The canonicalization rule fixes new writes going forward, but it
|
||||
does NOT fix rows that were already written under a registered
|
||||
alias before `fb6298a` landed. Those rows have a real, concrete
|
||||
gap that must be closed by a one-time migration before the
|
||||
engineering layer V1 ships.
|
||||
|
||||
The exact failure mode:
|
||||
|
||||
```
|
||||
time T0 (before fb6298a):
|
||||
POST /project/state {project: "p05", ...}
|
||||
-> set_state("p05", ...) # no canonicalization
|
||||
-> ensure_project("p05") # creates a "p05" row
|
||||
-> writes state with project_id pointing at the "p05" row
|
||||
|
||||
time T1 (after fb6298a):
|
||||
POST /project/state {project: "p05", ...} (or any read)
|
||||
-> set_state("p05", ...)
|
||||
-> resolve_project_name("p05") -> "p05-interferometer"
|
||||
-> ensure_project("p05-interferometer") # creates a SECOND row
|
||||
-> writes new state under the canonical row
|
||||
-> the T0 state is still in the "p05" row, INVISIBLE to every
|
||||
canonicalized read
|
||||
```
|
||||
|
||||
The unregistered-name fallback path saves you when the project was
|
||||
never in the registry: a row stored under `"orphan-project"` is read
|
||||
back via `"orphan-project"`, both pass through `resolve_project_name`
|
||||
unchanged, and the strings line up. **It does not save you when the
|
||||
name is a registered alias** — the helper rewrites the read key but
|
||||
not the storage key, and the legacy row becomes invisible.
|
||||
|
||||
What is at risk on the live Dalidou DB:
|
||||
|
||||
1. **`projects` table**: any rows whose `name` column matches a
|
||||
registered alias (one row per alias actually written under
|
||||
before the fix landed). These shadow the canonical project row
|
||||
and silently fragment the projects namespace.
|
||||
2. **`project_state` table**: any rows whose `project_id` points
|
||||
at one of those shadow project rows. **This is the highest-risk
|
||||
case** because it directly defeats the trust hierarchy: Layer 3
|
||||
trusted state becomes invisible to every canonicalized lookup.
|
||||
3. **`memories` table**: any rows whose `project` column is a
|
||||
registered alias. Reinforcement and extraction queries will
|
||||
miss them.
|
||||
4. **`interactions` table**: any rows whose `project` column is a
|
||||
registered alias. Listing and downstream reflection will miss
|
||||
them.
|
||||
|
||||
How to find out the actual blast radius on the live Dalidou DB:
|
||||
|
||||
```sql
|
||||
-- inspect the projects table for alias-shadow rows
|
||||
SELECT id, name FROM projects;
|
||||
|
||||
-- count alias-keyed memories per known alias
|
||||
SELECT project, COUNT(*) FROM memories
|
||||
WHERE project IN ('p04','p05','p06','gigabit','interferometer','polisher','ato core')
|
||||
GROUP BY project;
|
||||
|
||||
-- count alias-keyed interactions
|
||||
SELECT project, COUNT(*) FROM interactions
|
||||
WHERE project IN ('p04','p05','p06','gigabit','interferometer','polisher','ato core')
|
||||
GROUP BY project;
|
||||
|
||||
-- count alias-shadowed project_state rows by project name
|
||||
SELECT p.name, COUNT(*) FROM project_state ps
|
||||
JOIN projects p ON ps.project_id = p.id
|
||||
WHERE p.name IN ('p04','p05','p06','gigabit','interferometer','polisher','ato core');
|
||||
```
|
||||
|
||||
The migration that closes the gap has to:
|
||||
|
||||
1. For each registered project, find all `projects` rows whose
|
||||
name matches one of the project's aliases AND is not the
|
||||
canonical id itself. These are the "shadow" rows.
|
||||
2. For each shadow row, MERGE its dependent state into the
|
||||
canonical project's row:
|
||||
- rekey `project_state.project_id` from shadow → canonical
|
||||
- if the merge would create a `(project_id, category, key)`
|
||||
collision (a state row already exists under the canonical
|
||||
id with the same category+key), the migration must surface
|
||||
the conflict via the existing conflict model and pause
|
||||
until the human resolves it
|
||||
- delete the now-empty shadow `projects` row
|
||||
3. For `memories` and `interactions`, the fix is simpler because
|
||||
the alias appears as a string column (not a foreign key):
|
||||
`UPDATE memories SET project = canonical WHERE project = alias`,
|
||||
then same for interactions.
|
||||
4. The migration must run in dry-run mode first, printing the
|
||||
exact rows it would touch and the canonical destinations they
|
||||
would be merged into.
|
||||
5. The migration must be idempotent — running it twice produces
|
||||
the same final state as running it once.
|
||||
|
||||
This work is **required before the engineering layer V1 ships**
|
||||
because V1 will add new `entities`, `relationships`, `conflicts`,
|
||||
and `mirror_regeneration_failures` tables that all key on the
|
||||
canonical project id. Any leaked alias-keyed rows in the existing
|
||||
tables would show up in V1 reads as silently missing data, and
|
||||
the killer-correctness queries from `engineering-query-catalog.md`
|
||||
(orphan requirements, decisions on flagged assumptions,
|
||||
unsupported claims) would report wrong results against any project
|
||||
that has shadow rows.
|
||||
|
||||
The migration script does NOT exist yet. The open follow-ups
|
||||
section below tracks it as the next concrete step.
|
||||
|
||||
## The rule for new entry points
|
||||
|
||||
When you add a new service-layer function that takes a project name,
|
||||
follow this checklist:
|
||||
|
||||
1. **Does the function read or write a row keyed by project?** If
|
||||
yes, you must call `resolve_project_name`. If no (e.g. it only
|
||||
takes `project` as a label for logging), you may skip the
|
||||
canonicalization but you should add a comment explaining why.
|
||||
2. **Where does the canonicalization go?** As the first statement
|
||||
after input validation. Not later, not "before storage", not
|
||||
"in the helper that does the actual write". As the first
|
||||
statement, so any subsequent service call inside the function
|
||||
sees the canonical value.
|
||||
3. **Add a regression test that uses an alias.** Use the
|
||||
`project_registry` fixture from `tests/conftest.py` to set up
|
||||
a temp registry with at least one project + aliases, then
|
||||
verify the new function works when called with the alias and
|
||||
when called with the canonical id.
|
||||
4. **If the function can be called with `None` or empty string,
|
||||
verify that path too.** The helper handles it correctly but
|
||||
the function-under-test might not.
|
||||
|
||||
## How the `project_registry` test fixture works
|
||||
|
||||
`tests/conftest.py::project_registry` returns a callable that
|
||||
takes one or more `(project_id, [aliases])` tuples (or just a bare
|
||||
`project_id` string), writes them into a temp registry file,
|
||||
points `ATOCORE_PROJECT_REGISTRY_PATH` at it, and reloads
|
||||
`config.settings`. Use it like:
|
||||
|
||||
```python
|
||||
def test_my_new_thing_canonicalizes(project_registry):
|
||||
project_registry(("p05-interferometer", ["p05", "interferometer"]))
|
||||
|
||||
# ... call your service function with "p05" ...
|
||||
# ... assert it works the same as if you'd passed "p05-interferometer" ...
|
||||
```
|
||||
|
||||
The fixture is reused by all 12 alias-canonicalization regression
|
||||
tests added in `fb6298a`. Following the same pattern for new
|
||||
features is the cheapest way to keep the contract intact.
|
||||
|
||||
## What this rule does NOT cover
|
||||
|
||||
1. **Alias creation / management.** This document is about reading
|
||||
and writing project-keyed data. Adding new projects or new
|
||||
aliases is the registry's own write path
|
||||
(`POST /projects/register`, `PUT /projects/{name}`), which
|
||||
already enforces collision detection and atomic file writes.
|
||||
2. **Registry hot-reloading.** The helper calls
|
||||
`load_project_registry()` on every invocation, which reads the
|
||||
JSON file each time. There is no in-process cache. If the
|
||||
registry file changes, the next call sees the new contents.
|
||||
Performance is fine for the current registry size but if it
|
||||
becomes a bottleneck, add a versioned cache here, not at every
|
||||
call site.
|
||||
3. **Cross-project deduplication.** If two different projects in
|
||||
the registry happen to share an alias, the registry's collision
|
||||
detection blocks the second one at registration time, so this
|
||||
case can't arise in practice. The helper does not handle it
|
||||
defensively.
|
||||
4. **Time-bounded canonicalization.** A project's canonical id is
|
||||
stable. Aliases can be added or removed via
|
||||
`PUT /projects/{name}`, but the canonical `id` field never
|
||||
changes after registration. So a row written today under the
|
||||
canonical id will always remain findable under that id, even
|
||||
if the alias set evolves.
|
||||
5. **Migration of legacy data.** If the live Dalidou DB has rows
|
||||
that were written under aliases before the canonicalization
|
||||
landed (e.g. a `memories` row with `project = "p05"` from
|
||||
before `fb6298a`), those rows are **NOT** automatically
|
||||
reachable from the canonicalized read path. The unregistered-
|
||||
name fallback only helps for project names that were never
|
||||
registered at all; it does **NOT** help for names that are
|
||||
registered as aliases. See the "Compatibility gap" section
|
||||
below for the exact failure mode and the migration path that
|
||||
has to run before the engineering layer V1 ships.
|
||||
|
||||
## What this enables for the engineering layer V1
|
||||
|
||||
When the engineering layer ships per `engineering-v1-acceptance.md`,
|
||||
it adds at least these new project-keyed surfaces:
|
||||
|
||||
- `entities` table with a `project_id` column
|
||||
- `relationships` table that joins entities, indirectly project-keyed
|
||||
- `conflicts` table with a `project` column
|
||||
- `mirror_regeneration_failures` table with a `project` column
|
||||
- new endpoints: `POST /entities/...`, `POST /ingest/kb-cad/export`,
|
||||
`POST /ingest/kb-fem/export`, `GET /mirror/{project}/...`,
|
||||
`GET /conflicts?project=...`
|
||||
|
||||
**Every one of those write/read paths needs to call
|
||||
`resolve_project_name` at its service-layer entry point**, following
|
||||
the same pattern as the eight existing call sites listed above. The
|
||||
implementation sprint should:
|
||||
|
||||
1. Apply the helper at each new service entry point as the first
|
||||
statement after input validation
|
||||
2. Add a regression test using the `project_registry` fixture that
|
||||
exercises an alias against each new entry point
|
||||
3. Treat any new service function that takes a project name without
|
||||
calling `resolve_project_name` as a code review failure
|
||||
|
||||
The pattern is simple enough to follow without thinking, which is
|
||||
exactly the property we want for a contract that has to hold
|
||||
across many independent additions.
|
||||
|
||||
## Open follow-ups
|
||||
|
||||
These are things the canonicalization story still has open. None
|
||||
are blockers, but they're the rough edges to be aware of.
|
||||
|
||||
1. **Legacy alias data migration — REQUIRED before engineering V1
|
||||
ships, NOT optional.** If the live Dalidou DB has any rows
|
||||
written under aliases before `fb6298a` landed, they are
|
||||
silently invisible to the canonicalized read path (see the
|
||||
"Compatibility gap" section above for the exact failure mode).
|
||||
This is a real correctness issue, not a theoretical one: any
|
||||
trusted state, memory, or interaction stored under `p05`,
|
||||
`gigabit`, `polisher`, etc. before the fix landed is currently
|
||||
unreachable from any service-layer query. The migration script
|
||||
has to walk `projects`, `project_state`, `memories`, and
|
||||
`interactions`, merge shadow rows into their canonical
|
||||
counterparts (with conflict-model handling for any collisions),
|
||||
and run in dry-run mode first. Estimated cost: ~150 LOC for
|
||||
the migration script + ~50 LOC of tests + a one-time supervised
|
||||
run on the live Dalidou DB. **This migration is the next
|
||||
concrete pre-V1 step.**
|
||||
2. **Registry file caching.** `load_project_registry()` reads the
|
||||
JSON file on every `resolve_project_name` call. With ~5
|
||||
projects this is fine; with 50+ it would warrant a versioned
|
||||
cache (cache key = file mtime + size). Defer until measured.
|
||||
3. **Case sensitivity audit.** The helper uses
|
||||
`get_registered_project` which lowercases for comparison. The
|
||||
stored canonical id keeps its original casing. No bug today
|
||||
because every test passes, but worth re-confirming when the
|
||||
engineering layer adds entity-side storage.
|
||||
4. **`_rank_chunks`'s secondary substring boost.** Mentioned
|
||||
earlier; still uses the raw hint. Replace it with the same
|
||||
helper-driven approach the retriever uses, OR delete it as
|
||||
redundant once we confirm the retriever's primary boost is
|
||||
sufficient.
|
||||
5. **Documentation discoverability.** This doc lives under
|
||||
`docs/architecture/`. The contract is also restated in the
|
||||
docstring of `resolve_project_name` and referenced from each
|
||||
call site's comment. That redundancy is intentional — the
|
||||
contract is too easy to forget to live in only one place.
|
||||
|
||||
## Quick reference card
|
||||
|
||||
Copy-pasteable for new service functions:
|
||||
|
||||
```python
|
||||
from atocore.projects.registry import resolve_project_name
|
||||
|
||||
|
||||
def my_new_service_entry_point(
|
||||
project_name: str,
|
||||
other_args: ...,
|
||||
) -> ...:
|
||||
# Validate inputs first
|
||||
if not project_name:
|
||||
raise ValueError("project_name is required")
|
||||
|
||||
# Canonicalize through the registry as the first thing after
|
||||
# validation. Every subsequent operation in this function uses
|
||||
# the canonical id, so storage and queries are guaranteed
|
||||
# consistent across alias and canonical-id callers.
|
||||
project_name = resolve_project_name(project_name)
|
||||
|
||||
# ... rest of the function ...
|
||||
```
|
||||
|
||||
## TL;DR
|
||||
|
||||
- One helper, one rule: `resolve_project_name` at every service-layer
|
||||
entry point that takes a project name
|
||||
- Currently called in 8 places across builder, project_state,
|
||||
interactions, and memory; all 8 listed in this doc
|
||||
- Backwards-compat path returns **unregistered** names unchanged
|
||||
(e.g. `"orphan-project"`); this does NOT cover **registered
|
||||
alias** names that were used as storage keys before `fb6298a`
|
||||
- **Real compatibility gap**: any row whose `project` column is a
|
||||
registered alias from before the canonicalization landed is
|
||||
silently invisible to the new read path. A one-time migration
|
||||
is required before engineering V1 ships. See the "Compatibility
|
||||
gap" section.
|
||||
- The trust hierarchy depends on this helper being applied
|
||||
everywhere — Layer 3 trusted state has to be findable for it to
|
||||
win the trust battle
|
||||
- Use the `project_registry` test fixture to add regression tests
|
||||
for any new service function that takes a project name
|
||||
- The engineering layer V1 implementation must follow the same
|
||||
pattern at every new service entry point
|
||||
- Open follow-ups (in priority order): **legacy alias data
|
||||
migration (required pre-V1)**, redundant substring boost
|
||||
cleanup, registry caching when projects scale
|
||||
273
docs/architecture/representation-authority.md
Normal file
273
docs/architecture/representation-authority.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Representation Authority (canonical home matrix)
|
||||
|
||||
## Why this document exists
|
||||
|
||||
The same fact about an engineering project can show up in many
|
||||
places: a markdown note in the PKM, a structured field in KB-CAD,
|
||||
a commit message in a Gitea repo, an active memory in AtoCore, an
|
||||
entity in the engineering layer, a row in trusted project state.
|
||||
**Without an explicit rule about which representation is
|
||||
authoritative for which kind of fact, the system will accumulate
|
||||
contradictions and the human will lose trust in all of them.**
|
||||
|
||||
This document is the canonical-home matrix. Every kind of fact
|
||||
that AtoCore handles has exactly one authoritative representation,
|
||||
and every other place that holds a copy of that fact is, by
|
||||
definition, a derived view that may be stale.
|
||||
|
||||
## The representations in scope
|
||||
|
||||
Six places where facts can live in this ecosystem:
|
||||
|
||||
| Layer | What it is | Who edits it | How it's structured |
|
||||
|---|---|---|---|
|
||||
| **PKM** | Antoine's Obsidian-style markdown vault under `/srv/storage/atocore/sources/vault/` | Antoine, by hand | unstructured markdown with optional frontmatter |
|
||||
| **KB project** | the engineering Knowledge Base (KB-CAD / KB-FEM repos and any companion docs) | Antoine, semi-structured | per-tool typed records |
|
||||
| **Gitea repos** | source code repos under `dalidou:3000/Antoine/*` (Fullum-Interferometer, polisher-sim, ATOCore itself, ...) | Antoine via git commits | code, READMEs, repo-specific markdown |
|
||||
| **AtoCore memories** | rows in the `memories` table | hand-authored or extracted from interactions | typed (identity / preference / project / episodic / knowledge / adaptation) |
|
||||
| **AtoCore entities** | rows in the `entities` table (V1, not yet built) | imported from KB exports or extracted from interactions | typed entities + relationships per the V1 ontology |
|
||||
| **AtoCore project state** | rows in the `project_state` table (Layer 3, trusted) | hand-curated only, never automatic | category + key + value |
|
||||
|
||||
## The canonical home rule
|
||||
|
||||
> For each kind of fact, exactly one of the six representations is
|
||||
> the authoritative source. The other five may hold derived
|
||||
> copies, but they are not allowed to disagree with the
|
||||
> authoritative one. When they disagree, the disagreement is a
|
||||
> conflict and surfaces via the conflict model.
|
||||
|
||||
The matrix below assigns the authoritative representation per fact
|
||||
kind. It is the practical answer to the question "where does this
|
||||
fact actually live?" for daily decisions.
|
||||
|
||||
## The canonical-home matrix
|
||||
|
||||
| Fact kind | Canonical home | Why | How it gets into AtoCore |
|
||||
|---|---|---|---|
|
||||
| **CAD geometry** (the actual model) | NX (or successor CAD tool) | the only place that can render and validate it | not in AtoCore at all in V1 |
|
||||
| **CAD-side structure** (subsystem tree, component list, materials, parameters) | KB-CAD | KB-CAD is the structured wrapper around NX | KB-CAD export → `/ingest/kb-cad/export` → entities |
|
||||
| **FEM mesh & solver settings** | KB-FEM (wrapping the FEM tool) | only the solver representation can run | not in AtoCore at all in V1 |
|
||||
| **FEM results & validation outcomes** | KB-FEM | KB-FEM owns the outcome records | KB-FEM export → `/ingest/kb-fem/export` → entities |
|
||||
| **Source code** | Gitea repos | repos are version-controlled and reviewable | indirectly via repo markdown ingestion (Phase 1) |
|
||||
| **Repo-level documentation** (READMEs, design docs in the repo) | Gitea repos | lives next to the code it documents | ingested as source chunks; never hand-edited in AtoCore |
|
||||
| **Project-level prose notes** (decisions in long-form, journal-style entries, working notes) | PKM | the place Antoine actually writes when thinking | ingested as source chunks; the extractor proposes candidates from these for the review queue |
|
||||
| **Identity** ("the user is a mechanical engineer running AtoCore") | AtoCore memories (`identity` type) | nowhere else holds personal identity | hand-authored via `POST /memory` or extracted from interactions |
|
||||
| **Preference** ("prefers small reviewable diffs", "uses SI units") | AtoCore memories (`preference` type) | nowhere else holds personal preferences | hand-authored or extracted |
|
||||
| **Episodic** ("on April 6 we debugged the EXDEV bug") | AtoCore memories (`episodic` type) | nowhere else has time-bound personal recall | extracted from captured interactions |
|
||||
| **Decision** (a structured engineering decision) | AtoCore **entities** (Decision) once the engineering layer ships; AtoCore memories (`adaptation`) until then | needs structured supersession, audit trail, and link to affected components | extracted from PKM or interactions; promoted via review queue |
|
||||
| **Requirement** | AtoCore **entities** (Requirement) | needs structured satisfaction tracking | extracted from PKM, KB-CAD, or interactions |
|
||||
| **Constraint** | AtoCore **entities** (Constraint) | needs structured link to the entity it constrains | extracted from PKM, KB-CAD, or interactions |
|
||||
| **Validation claim** | AtoCore **entities** (ValidationClaim) | needs structured link to supporting Result | extracted from KB-FEM exports or interactions |
|
||||
| **Material** | KB-CAD if the material is on a real component; AtoCore entity (Material) if it's a project-wide material decision not yet attached to geometry | structured properties live in KB-CAD's material database | KB-CAD export, or hand-authored as a Material entity |
|
||||
| **Parameter** | KB-CAD or KB-FEM depending on whether it's a geometry or solver parameter; AtoCore entity (Parameter) if it's a higher-level project parameter not in either tool | structured numeric values with units live in their tool of origin | KB export, or hand-authored |
|
||||
| **Project status / current focus / next milestone** | AtoCore **project_state** (Layer 3) | the trust hierarchy says trusted state is the highest authority for "what is the current state of the project" | hand-curated via `POST /project/state` |
|
||||
| **Architectural decision records (ADRs)** | depends on form: long-form ADR markdown lives in the repo; the structured fact about which ADR was selected lives in the AtoCore Decision entity | both representations are useful for different audiences | repo ingestion provides the prose; the entity is created by extraction or hand-authored |
|
||||
| **Operational runbooks** | repo (next to the code they describe) | lives with the system it operates | not promoted into AtoCore entities — runbooks are reference material, not facts |
|
||||
| **Backup metadata** (snapshot timestamps, integrity status) | the backup-metadata.json files under `/srv/storage/atocore/backups/` | each snapshot is its own self-describing record | not in AtoCore's database; queried via the `/admin/backup` endpoints |
|
||||
| **Conversation history with AtoCore (interactions)** | AtoCore `interactions` table | nowhere else has the prompt + context pack + response triple | written by capture (Phase 9 Commit A) |
|
||||
|
||||
## The supremacy rule for cross-layer facts
|
||||
|
||||
When the same fact has copies in multiple representations and they
|
||||
disagree, the trust hierarchy applies in this order:
|
||||
|
||||
1. **AtoCore project_state** (Layer 3) is highest authority for any
|
||||
"current state of the project" question. This is why it requires
|
||||
manual curation and never gets touched by automatic processes.
|
||||
2. **The tool-of-origin canonical home** is highest authority for
|
||||
facts that are tool-managed: KB-CAD wins over AtoCore entities
|
||||
for CAD-side structure facts; KB-FEM wins for FEM result facts.
|
||||
3. **AtoCore entities** are highest authority for facts that are
|
||||
AtoCore-managed: Decisions, Requirements, Constraints,
|
||||
ValidationClaims (when the supporting Results are still loose).
|
||||
4. **Active AtoCore memories** are highest authority for personal
|
||||
facts (identity, preference, episodic).
|
||||
5. **Source chunks (PKM, repos, ingested docs)** are lowest
|
||||
authority — they are the raw substrate from which higher layers
|
||||
are extracted, but they may be stale, contradictory among
|
||||
themselves, or out of date.
|
||||
|
||||
This is the same hierarchy enforced by `conflict-model.md`. This
|
||||
document just makes it explicit per fact kind.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1 — "what material does the lateral support pad use?"
|
||||
|
||||
Possible representations:
|
||||
|
||||
- KB-CAD has the field `component.lateral-support-pad.material = "GF-PTFE"`
|
||||
- A PKM note from last month says "considering PEEK for the
|
||||
lateral support, GF-PTFE was the previous choice"
|
||||
- An AtoCore Material entity says `GF-PTFE`
|
||||
- An AtoCore project_state entry says `p05 / decision /
|
||||
lateral_support_material = GF-PTFE`
|
||||
|
||||
Which one wins for the question "what's the current material"?
|
||||
|
||||
- **project_state wins** if the query is "what is the current
|
||||
trusted answer for p05's lateral support material" (Layer 3)
|
||||
- **KB-CAD wins** if project_state has not been curated for this
|
||||
field yet, because KB-CAD is the canonical home for CAD-side
|
||||
structure
|
||||
- **The Material entity** is a derived view from KB-CAD; if it
|
||||
disagrees with KB-CAD, the entity is wrong and a conflict is
|
||||
surfaced
|
||||
- **The PKM note** is historical context, not authoritative for
|
||||
"current"
|
||||
|
||||
### Example 2 — "did we decide to merge the bind mounts?"
|
||||
|
||||
Possible representations:
|
||||
|
||||
- A working session interaction is captured in the `interactions`
|
||||
table with the response containing `## Decision: merge the two
|
||||
bind mounts into one`
|
||||
- The Phase 9 Commit C extractor produced a candidate adaptation
|
||||
memory from that decision
|
||||
- A reviewer promoted the candidate to active
|
||||
- The AtoCore source repo has the actual code change in commit
|
||||
`d0ff8b5` and the docker-compose.yml is in its post-merge form
|
||||
|
||||
Which one wins for "is this decision real and current"?
|
||||
|
||||
- **The Gitea repo** wins for "is this decision implemented" —
|
||||
the docker-compose.yml is the canonical home for the actual
|
||||
bind mount configuration
|
||||
- **The active adaptation memory** wins for "did we decide this"
|
||||
— that's exactly what the Commit C lifecycle is for
|
||||
- **The interaction record** is the audit trail — it's
|
||||
authoritative for "when did this conversation happen and what
|
||||
did the LLM say", but not for "is this decision current"
|
||||
- **The source chunks** from PKM are not relevant here because no
|
||||
PKM note about this decision exists yet (and that's fine —
|
||||
decisions don't have to live in PKM if they live in the repo
|
||||
and the AtoCore memory)
|
||||
|
||||
### Example 3 — "what's p05's current next focus?"
|
||||
|
||||
Possible representations:
|
||||
|
||||
- The PKM has a `current-status.md` note updated last week
|
||||
- AtoCore project_state has `p05 / status / next_focus = "wave 2 ingestion"`
|
||||
- A captured interaction from yesterday discussed the next focus
|
||||
at length
|
||||
|
||||
Which one wins?
|
||||
|
||||
- **project_state wins**, full stop. The trust hierarchy says
|
||||
Layer 3 is canonical for current state. This is exactly the
|
||||
reason project_state exists.
|
||||
- The PKM note is historical context.
|
||||
- The interaction is conversation history.
|
||||
- If project_state and the PKM disagree, the human updates one or
|
||||
the other to bring them in line — usually by re-curating
|
||||
project_state if the conversation revealed a real change.
|
||||
|
||||
## What this means for the engineering layer V1 implementation
|
||||
|
||||
Several concrete consequences fall out of the matrix:
|
||||
|
||||
1. **The Material and Parameter entity types are mostly KB-CAD
|
||||
shadows in V1.** They exist in AtoCore so other entities
|
||||
(Decisions, Requirements) can reference them with structured
|
||||
links, but their authoritative values come from KB-CAD imports.
|
||||
If KB-CAD doesn't know about a material, the AtoCore entity is
|
||||
the canonical home only because nothing else is.
|
||||
2. **Decisions / Requirements / Constraints / ValidationClaims
|
||||
are AtoCore-canonical.** These don't have a natural home in
|
||||
KB-CAD or KB-FEM. They live in AtoCore as first-class entities
|
||||
with full lifecycle and supersession.
|
||||
3. **The PKM is never authoritative.** It is the substrate for
|
||||
extraction. The reviewer promotes things out of it; they don't
|
||||
point at PKM notes as the "current truth".
|
||||
4. **project_state is the override layer.** Whenever the human
|
||||
wants to declare "the current truth is X regardless of what
|
||||
the entities and memories and KB exports say", they curate
|
||||
into project_state. Layer 3 is intentionally small and
|
||||
intentionally manual.
|
||||
5. **The conflict model is the enforcement mechanism.** When two
|
||||
representations disagree on a fact whose canonical home rule
|
||||
should pick a winner, the conflict surfaces via the
|
||||
`/conflicts` endpoint and the reviewer resolves it. The
|
||||
matrix in this document tells the reviewer who is supposed
|
||||
to win in each scenario; they're not making the decision blind.
|
||||
|
||||
## What the matrix does NOT define
|
||||
|
||||
1. **Facts about people other than the user.** No "team member"
|
||||
entity, no per-collaborator preferences. AtoCore is
|
||||
single-user in V1.
|
||||
2. **Facts about AtoCore itself as a project.** Those are project
|
||||
memories and project_state entries under `project=atocore`,
|
||||
same lifecycle as any other project's facts.
|
||||
3. **Vendor / supplier / cost facts.** Out of V1 scope.
|
||||
4. **Time-bounded facts** (a value that was true between two
|
||||
dates and may not be true now). The current matrix treats all
|
||||
active facts as currently-true and uses supersession to
|
||||
represent change. Temporal facts are a V2 concern.
|
||||
5. **Cross-project shared facts** (a Material that is reused across
|
||||
p04, p05, and p06). Currently each project has its own copy.
|
||||
Cross-project deduplication is also a V2 concern.
|
||||
|
||||
## The "single canonical home" invariant in practice
|
||||
|
||||
The hard rule that every fact has exactly one canonical home is
|
||||
the load-bearing invariant of this matrix. To enforce it
|
||||
operationally:
|
||||
|
||||
- **Extraction never duplicates.** When the extractor scans an
|
||||
interaction or a source chunk and proposes a candidate, the
|
||||
candidate is dropped if it duplicates an already-active record
|
||||
in the canonical home (the existing extractor implementation
|
||||
already does this for memories; the entity extractor will
|
||||
follow the same pattern).
|
||||
- **Imports never duplicate.** When KB-CAD pushes the same
|
||||
Component twice with the same value, the second push is
|
||||
recognized as identical and updates the `last_imported_at`
|
||||
timestamp without creating a new entity.
|
||||
- **Imports surface drift as conflict.** When KB-CAD pushes the
|
||||
same Component with a different value, that's a conflict per
|
||||
the conflict model — never a silent overwrite.
|
||||
- **Hand-curation into project_state always wins.** A
|
||||
project_state entry can disagree with an entity or a KB
|
||||
export; the project_state entry is correct by fiat (Layer 3
|
||||
trust), and the reviewer is responsible for bringing the lower
|
||||
layers in line if appropriate.
|
||||
|
||||
## Open questions for V1 implementation
|
||||
|
||||
1. **How does the reviewer see the canonical home for a fact in
|
||||
the UI?** Probably by including the fact's authoritative
|
||||
layer in the entity / memory detail view: "this Material is
|
||||
currently mirrored from KB-CAD; the canonical home is KB-CAD".
|
||||
2. **Who owns running the KB-CAD / KB-FEM exporter?** The
|
||||
`tool-handoff-boundaries.md` doc lists this as an open
|
||||
question; same answer applies here.
|
||||
3. **Do we need an explicit `canonical_home` field on entity
|
||||
rows?** A field that records "this entity is canonical here"
|
||||
vs "this entity is a mirror of <external system>". Probably
|
||||
yes; deferred to the entity schema spec.
|
||||
4. **How are project_state overrides surfaced in the engineering
|
||||
layer query results?** When a query (e.g. Q-001 "what does
|
||||
this subsystem contain?") would return entity rows, the result
|
||||
should also flag any project_state entries that contradict the
|
||||
entities — letting the reviewer see the override at query
|
||||
time, not just in the conflict queue.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- Six representation layers: PKM, KB project, repos, AtoCore
|
||||
memories, AtoCore entities, AtoCore project_state
|
||||
- Every fact kind has exactly one canonical home
|
||||
- The trust hierarchy resolves cross-layer conflicts:
|
||||
project_state > tool-of-origin (KB-CAD/KB-FEM) > entities >
|
||||
active memories > source chunks
|
||||
- Decisions / Requirements / Constraints / ValidationClaims are
|
||||
AtoCore-canonical (no other system has a natural home for them)
|
||||
- Materials / Parameters / CAD-side structure are KB-CAD-canonical
|
||||
- FEM results / validation outcomes are KB-FEM-canonical
|
||||
- project_state is the human override layer, top of the
|
||||
hierarchy, manually curated only
|
||||
- Conflicts surface via `/conflicts` and the reviewer applies the
|
||||
matrix to pick a winner
|
||||
339
docs/architecture/tool-handoff-boundaries.md
Normal file
339
docs/architecture/tool-handoff-boundaries.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# Tool Hand-off Boundaries (KB-CAD / KB-FEM and friends)
|
||||
|
||||
## Why this document exists
|
||||
|
||||
The engineering layer V1 will accumulate typed entities about
|
||||
projects, subsystems, components, materials, requirements,
|
||||
constraints, decisions, parameters, analysis models, results, and
|
||||
validation claims. Many of those concepts also live in real
|
||||
external tools — CAD systems, FEM solvers, BOM managers, PLM
|
||||
databases, vendor portals.
|
||||
|
||||
The first big design decision before writing any entity-layer code
|
||||
is: **what is AtoCore's read/write relationship with each of those
|
||||
external tools?**
|
||||
|
||||
The wrong answer in either direction is expensive:
|
||||
|
||||
- Too read-only: AtoCore becomes a stale shadow of the tools and
|
||||
loses the trust battle the moment a value drifts.
|
||||
- Too bidirectional: AtoCore takes on responsibilities it can't
|
||||
reliably honor (live sync, conflict resolution against external
|
||||
schemas, write-back validation), and the project never ships.
|
||||
|
||||
This document picks a position for V1.
|
||||
|
||||
## The position
|
||||
|
||||
> **AtoCore is a one-way mirror in V1.** External tools push
|
||||
> structured exports into AtoCore. AtoCore never pushes back.
|
||||
|
||||
That position has three corollaries:
|
||||
|
||||
1. **External tools remain the source of truth for everything they
|
||||
already manage.** A CAD model is canonical for geometry; a FEM
|
||||
project is canonical for meshes and solver settings; KB-CAD is
|
||||
canonical for whatever KB-CAD already calls canonical.
|
||||
2. **AtoCore is the source of truth for the *AtoCore-shaped*
|
||||
record** of those facts: the Decision that selected the geometry,
|
||||
the Requirement the geometry satisfies, the ValidationClaim the
|
||||
FEM result supports. AtoCore does not duplicate the external
|
||||
tool's primary representation; it stores the structured *facts
|
||||
about* it.
|
||||
3. **The boundary is enforced by absence.** No write endpoint in
|
||||
AtoCore ever generates a `.prt`, a `.fem`, an export to a PLM
|
||||
schema, or a vendor purchase order. If we find ourselves wanting
|
||||
to add such an endpoint in V1, we should stop and reconsider
|
||||
the V1 scope.
|
||||
|
||||
## Why one-way and not bidirectional
|
||||
|
||||
Bidirectional sync between independent systems is one of the
|
||||
hardest problems in engineering software. The honest reasons we
|
||||
are not attempting it in V1:
|
||||
|
||||
1. **Schema drift.** External tools evolve their schemas
|
||||
independently. A bidirectional sync would have to track every
|
||||
schema version of every external tool we touch. That is a
|
||||
permanent maintenance tax.
|
||||
2. **Conflict semantics.** When AtoCore and an external tool
|
||||
disagree on the same field, "who wins" is a per-tool, per-field
|
||||
decision. There is no general rule. Bidirectional sync would
|
||||
require us to specify that decision exhaustively.
|
||||
3. **Trust hierarchy.** AtoCore's whole point is the trust
|
||||
hierarchy: trusted project state > entities > memories. If we
|
||||
let entities push values back into the external tools, we
|
||||
silently elevate AtoCore's confidence to "high enough to write
|
||||
to a CAD model", which it almost never deserves.
|
||||
4. **Velocity.** A bidirectional engineering layer is a
|
||||
multi-year project. A one-way mirror is a months project. The
|
||||
value-to-effort ratio favors one-way for V1 by an enormous
|
||||
margin.
|
||||
5. **Reversibility.** We can always add bidirectional sync later
|
||||
on a per-tool basis once V1 has shown itself to be useful. We
|
||||
cannot easily walk back a half-finished bidirectional sync that
|
||||
has already corrupted data in someone's CAD model.
|
||||
|
||||
## Per-tool stance for V1
|
||||
|
||||
| External tool | V1 stance | What AtoCore reads in | What AtoCore writes back |
|
||||
|---|---|---|---|
|
||||
| **KB-CAD** (Antoine's CAD knowledge base) | one-way mirror | structured exports of subsystems, components, materials, parameters via a documented JSON or CSV shape | nothing |
|
||||
| **KB-FEM** (Antoine's FEM knowledge base) | one-way mirror | structured exports of analysis models, results, validation claims | nothing |
|
||||
| **NX / Siemens NX** (the CAD tool itself) | not connected in V1 | nothing direct — only what KB-CAD exports about NX projects | nothing |
|
||||
| **PKM (Obsidian / markdown vault)** | already connected via the ingestion pipeline (Phase 1) | full markdown/text corpus per the ingestion-waves doc | nothing |
|
||||
| **Gitea repos** | already connected via the ingestion pipeline | repo markdown/text per project | nothing |
|
||||
| **OpenClaw** (the LLM agent) | already connected via the read-only helper skill on the T420 | nothing — OpenClaw reads from AtoCore | nothing — OpenClaw does not write into AtoCore |
|
||||
| **AtoDrive** (operational truth layer, future) | future: bidirectional with AtoDrive itself, but AtoDrive is internal to AtoCore so this isn't an external tool boundary | n/a in V1 | n/a in V1 |
|
||||
| **PLM / vendor portals / cost systems** | not in V1 scope | nothing | nothing |
|
||||
|
||||
## What "one-way mirror" actually looks like in code
|
||||
|
||||
AtoCore exposes an ingestion endpoint per external tool that
|
||||
accepts a structured export and turns it into entity candidates.
|
||||
The endpoint is read-side from AtoCore's perspective (it reads
|
||||
from a file or HTTP body), even though the external tool is the
|
||||
one initiating the call.
|
||||
|
||||
Proposed V1 ingestion endpoints:
|
||||
|
||||
```
|
||||
POST /ingest/kb-cad/export body: KB-CAD export JSON
|
||||
POST /ingest/kb-fem/export body: KB-FEM export JSON
|
||||
```
|
||||
|
||||
Each endpoint:
|
||||
|
||||
1. Validates the export against the documented schema
|
||||
2. Maps each export record to an entity candidate (status="candidate")
|
||||
3. Carries the export's source identifier into the candidate's
|
||||
provenance fields (source_artifact_id, exporter_version, etc.)
|
||||
4. Returns a summary: how many candidates were created, how many
|
||||
were dropped as duplicates, how many failed schema validation
|
||||
5. Does NOT auto-promote anything
|
||||
|
||||
The KB-CAD and KB-FEM teams (which is to say, future-you) own the
|
||||
exporter scripts that produce these JSON bodies. Those scripts
|
||||
live in the KB-CAD / KB-FEM repos respectively, not in AtoCore.
|
||||
|
||||
## The export schemas (sketch, not final)
|
||||
|
||||
These are starting shapes, intentionally minimal. The schemas
|
||||
will be refined in `kb-cad-export-schema.md` and
|
||||
`kb-fem-export-schema.md` once the V1 ontology lands.
|
||||
|
||||
### KB-CAD export shape (starting sketch)
|
||||
|
||||
```json
|
||||
{
|
||||
"exporter": "kb-cad",
|
||||
"exporter_version": "1.0.0",
|
||||
"exported_at": "2026-04-07T12:00:00Z",
|
||||
"project": "p05-interferometer",
|
||||
"subsystems": [
|
||||
{
|
||||
"id": "subsystem.optical-frame",
|
||||
"name": "Optical frame",
|
||||
"parent": null,
|
||||
"components": [
|
||||
{
|
||||
"id": "component.lateral-support-pad",
|
||||
"name": "Lateral support pad",
|
||||
"material": "GF-PTFE",
|
||||
"parameters": {
|
||||
"thickness_mm": 3.0,
|
||||
"preload_n": 12.0
|
||||
},
|
||||
"source_artifact": "kb-cad://p05/subsystems/optical-frame#lateral-support"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### KB-FEM export shape (starting sketch)
|
||||
|
||||
```json
|
||||
{
|
||||
"exporter": "kb-fem",
|
||||
"exporter_version": "1.0.0",
|
||||
"exported_at": "2026-04-07T12:00:00Z",
|
||||
"project": "p05-interferometer",
|
||||
"analysis_models": [
|
||||
{
|
||||
"id": "model.optical-frame-modal",
|
||||
"name": "Optical frame modal analysis v3",
|
||||
"subsystem": "subsystem.optical-frame",
|
||||
"results": [
|
||||
{
|
||||
"id": "result.first-mode-frequency",
|
||||
"name": "First-mode frequency",
|
||||
"value": 187.4,
|
||||
"unit": "Hz",
|
||||
"supports_validation_claim": "claim.frame-rigidity-min-150hz",
|
||||
"source_artifact": "kb-fem://p05/models/optical-frame-modal#first-mode"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
These shapes will evolve. The point of including them now is to
|
||||
make the one-way mirror concrete: it is a small, well-defined
|
||||
JSON shape, not "AtoCore reaches into KB-CAD's database".
|
||||
|
||||
## What AtoCore is allowed to do with the imported records
|
||||
|
||||
After ingestion, the imported records become entity candidates
|
||||
in AtoCore's own table. From that point forward they follow the
|
||||
exact same lifecycle as any other candidate:
|
||||
|
||||
- they sit at status="candidate" until a human reviews them
|
||||
- the reviewer promotes them to status="active" or rejects them
|
||||
- the active entities are queryable via the engineering query
|
||||
catalog (Q-001 through Q-020)
|
||||
- the active entities can be referenced from Decisions, Requirements,
|
||||
ValidationClaims, etc. via the V1 relationship types
|
||||
|
||||
The imported records are never automatically pushed into trusted
|
||||
project state, never modified in place after import (they are
|
||||
superseded by re-imports, not edited), and never written back to
|
||||
the external tool.
|
||||
|
||||
## What happens when KB-CAD changes a value AtoCore already has
|
||||
|
||||
This is the canonical "drift" scenario. The flow:
|
||||
|
||||
1. KB-CAD exports a fresh JSON. Component `component.lateral-support-pad`
|
||||
now has `material: "PEEK"` instead of `material: "GF-PTFE"`.
|
||||
2. AtoCore's ingestion endpoint sees the same `id` and a different
|
||||
value.
|
||||
3. The ingestion endpoint creates a new entity candidate with the
|
||||
new value, **does NOT delete or modify the existing active
|
||||
entity**, and creates a `conflicts` row linking the two members
|
||||
(per the conflict model doc).
|
||||
4. The reviewer sees an open conflict on the next visit to
|
||||
`/conflicts`.
|
||||
5. The reviewer either:
|
||||
- **promotes the new value** (the active is superseded, the
|
||||
candidate becomes the new active, the audit trail keeps both)
|
||||
- **rejects the new value** (the candidate is invalidated, the
|
||||
active stays — useful when the export was wrong)
|
||||
- **dismisses the conflict** (declares them not actually about
|
||||
the same thing, both stay active)
|
||||
|
||||
The reviewer never touches KB-CAD from AtoCore. If the resolution
|
||||
implies a change in KB-CAD itself, the reviewer makes that change
|
||||
in KB-CAD, then re-exports.
|
||||
|
||||
## What about NX directly?
|
||||
|
||||
NX (Siemens NX) is the underlying CAD tool that KB-CAD wraps.
|
||||
**NX is not connected to AtoCore in V1.** Any facts about NX
|
||||
projects flow through KB-CAD as the structured intermediate. This
|
||||
gives us:
|
||||
|
||||
- **One schema to maintain.** AtoCore only has to understand the
|
||||
KB-CAD export shape, not the NX API.
|
||||
- **One ownership boundary.** KB-CAD owns the question of "what's
|
||||
in NX". AtoCore owns the question of "what's in the typed
|
||||
knowledge base".
|
||||
- **Future flexibility.** When NX is replaced or upgraded, only
|
||||
KB-CAD has to adapt; AtoCore doesn't notice.
|
||||
|
||||
The same logic applies to FEM solvers (Nastran, Abaqus, ANSYS):
|
||||
KB-FEM is the structured intermediate, AtoCore never talks to the
|
||||
solver directly.
|
||||
|
||||
## The hard-line invariants
|
||||
|
||||
These are the things V1 will not do, regardless of how convenient
|
||||
they might seem:
|
||||
|
||||
1. **No write to external tools.** No POST/PUT/PATCH to any
|
||||
external API, no file generation that gets written into a
|
||||
CAD/FEM project tree, no email/chat sends.
|
||||
2. **No live polling.** AtoCore does not poll KB-CAD or KB-FEM on
|
||||
a schedule. Imports are explicit pushes from the external tool
|
||||
into AtoCore's ingestion endpoint.
|
||||
3. **No silent merging.** Every value drift surfaces as a
|
||||
conflict for the reviewer (per the conflict model doc).
|
||||
4. **No schema fan-out.** AtoCore does not store every field that
|
||||
KB-CAD knows about. Only fields that map to one of the V1
|
||||
entity types make it into AtoCore. Everything else is dropped
|
||||
at the import boundary.
|
||||
5. **No external-tool-specific logic in entity types.** A
|
||||
`Component` in AtoCore is the same shape regardless of whether
|
||||
it came from KB-CAD, KB-FEM, the PKM, or a hand-curated
|
||||
project state entry. The source is recorded in provenance,
|
||||
not in the entity shape.
|
||||
|
||||
## What this enables
|
||||
|
||||
With the one-way mirror locked in, V1 implementation can focus on:
|
||||
|
||||
- The entity table and its lifecycle
|
||||
- The two `/ingest/kb-cad/export` and `/ingest/kb-fem/export`
|
||||
endpoints with their JSON validators
|
||||
- The candidate review queue extension (already designed in
|
||||
`promotion-rules.md`)
|
||||
- The conflict model (already designed in `conflict-model.md`)
|
||||
- The query catalog implementation (already designed in
|
||||
`engineering-query-catalog.md`)
|
||||
|
||||
None of those are unbounded. Each is a finite, well-defined
|
||||
implementation task. The one-way mirror is the choice that makes
|
||||
V1 finishable.
|
||||
|
||||
## What V2 might consider (deferred)
|
||||
|
||||
After V1 has been live and demonstrably useful for a quarter or
|
||||
two, the questions that become reasonable to revisit:
|
||||
|
||||
1. **Selective write-back to KB-CAD for low-risk fields.** For
|
||||
example, AtoCore could push back a "Decision id linked to this
|
||||
component" annotation that KB-CAD then displays without it
|
||||
being canonical there. Read-only annotations from AtoCore's
|
||||
perspective, advisory metadata from KB-CAD's perspective.
|
||||
2. **Live polling for very small payloads.** A daily poll of
|
||||
"what subsystem ids exist in KB-CAD now" so AtoCore can flag
|
||||
subsystems that disappeared from KB-CAD without an explicit
|
||||
AtoCore invalidation.
|
||||
3. **Direct NX integration** if the KB-CAD layer becomes a
|
||||
bottleneck — but only if the friction is real, not theoretical.
|
||||
4. **Cost / vendor / PLM connections** for projects where the
|
||||
procurement cycle is part of the active engineering work.
|
||||
|
||||
None of these are V1 work and they are listed only so the V1
|
||||
design intentionally leaves room for them later.
|
||||
|
||||
## Open questions for the V1 implementation sprint
|
||||
|
||||
1. **Where do the export schemas live?** Probably in
|
||||
`docs/architecture/kb-cad-export-schema.md` and
|
||||
`docs/architecture/kb-fem-export-schema.md`, drafted during
|
||||
the implementation sprint.
|
||||
2. **Who runs the exporter?** A scheduled job on the KB-CAD /
|
||||
KB-FEM hosts, triggered by the human after a meaningful
|
||||
change, or both?
|
||||
3. **Is the export incremental or full?** Full is simpler but
|
||||
more expensive. Incremental needs delta semantics. V1 starts
|
||||
with full and revisits when full becomes too slow.
|
||||
4. **How is the exporter authenticated to AtoCore?** Probably
|
||||
the existing PAT model (one PAT per exporter, scoped to
|
||||
`write:engineering-import` once that scope exists). Worth a
|
||||
quick auth design pass before the endpoints exist.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- AtoCore is a one-way mirror in V1: external tools push,
|
||||
AtoCore reads, AtoCore never writes back
|
||||
- Two import endpoints for V1: KB-CAD and KB-FEM, each with a
|
||||
documented JSON export shape
|
||||
- Drift surfaces as conflicts in the existing conflict model
|
||||
- No NX, no FEM solvers, no PLM, no vendor portals, no
|
||||
cost/BOM systems in V1
|
||||
- Bidirectional sync is reserved for V2+ on a per-tool basis,
|
||||
only after V1 demonstrates value
|
||||
442
docs/backup-restore-procedure.md
Normal file
442
docs/backup-restore-procedure.md
Normal file
@@ -0,0 +1,442 @@
|
||||
# AtoCore Backup and Restore Procedure
|
||||
|
||||
## Scope
|
||||
|
||||
This document defines the operational procedure for backing up and
|
||||
restoring AtoCore's machine state on the Dalidou deployment. It is
|
||||
the practical companion to `docs/backup-strategy.md` (which defines
|
||||
the strategy) and `src/atocore/ops/backup.py` (which implements the
|
||||
mechanics).
|
||||
|
||||
The intent is that this procedure can be followed by anyone with
|
||||
SSH access to Dalidou and the AtoCore admin endpoints.
|
||||
|
||||
## What gets backed up
|
||||
|
||||
A `create_runtime_backup` snapshot contains, in order of importance:
|
||||
|
||||
| Artifact | Source path on Dalidou | Backup destination | Always included |
|
||||
|---|---|---|---|
|
||||
| SQLite database | `/srv/storage/atocore/data/db/atocore.db` | `<backup_root>/db/atocore.db` | yes |
|
||||
| Project registry JSON | `/srv/storage/atocore/config/project-registry.json` | `<backup_root>/config/project-registry.json` | yes (if file exists) |
|
||||
| Backup metadata | (generated) | `<backup_root>/backup-metadata.json` | yes |
|
||||
| Chroma vector store | `/srv/storage/atocore/data/chroma/` | `<backup_root>/chroma/` | only when `include_chroma=true` |
|
||||
|
||||
The SQLite snapshot uses the online `conn.backup()` API and is safe
|
||||
to take while the database is in use. The Chroma snapshot is a cold
|
||||
directory copy and is **only safe when no ingestion is running**;
|
||||
the API endpoint enforces this by acquiring the ingestion lock for
|
||||
the duration of the copy.
|
||||
|
||||
What is **not** in the backup:
|
||||
|
||||
- Source documents under `/srv/storage/atocore/sources/vault/` and
|
||||
`/srv/storage/atocore/sources/drive/`. These are read-only
|
||||
inputs and live in the user's PKM/Drive, which is backed up
|
||||
separately by their own systems.
|
||||
- Application code. The container image is the source of truth for
|
||||
code; recovery means rebuilding the image, not restoring code from
|
||||
a backup.
|
||||
- Logs under `/srv/storage/atocore/logs/`.
|
||||
- Embeddings cache under `/srv/storage/atocore/data/cache/`.
|
||||
- Temp files under `/srv/storage/atocore/data/tmp/`.
|
||||
|
||||
## Backup root layout
|
||||
|
||||
Each backup snapshot lives in its own timestamped directory:
|
||||
|
||||
```
|
||||
/srv/storage/atocore/backups/snapshots/
|
||||
├── 20260407T060000Z/
|
||||
│ ├── backup-metadata.json
|
||||
│ ├── db/
|
||||
│ │ └── atocore.db
|
||||
│ ├── config/
|
||||
│ │ └── project-registry.json
|
||||
│ └── chroma/ # only if include_chroma=true
|
||||
│ └── ...
|
||||
├── 20260408T060000Z/
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
The timestamp is UTC, format `YYYYMMDDTHHMMSSZ`.
|
||||
|
||||
## Triggering a backup
|
||||
|
||||
### Option A — via the admin endpoint (preferred)
|
||||
|
||||
```bash
|
||||
# DB + registry only (fast, safe at any time)
|
||||
curl -fsS -X POST http://dalidou:8100/admin/backup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"include_chroma": false}'
|
||||
|
||||
# DB + registry + Chroma (acquires ingestion lock)
|
||||
curl -fsS -X POST http://dalidou:8100/admin/backup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"include_chroma": true}'
|
||||
```
|
||||
|
||||
The response is the backup metadata JSON. Save the `backup_root`
|
||||
field — that's the directory the snapshot was written to.
|
||||
|
||||
### Option B — via the standalone script (when the API is down)
|
||||
|
||||
```bash
|
||||
docker exec atocore python -m atocore.ops.backup
|
||||
```
|
||||
|
||||
This runs `create_runtime_backup()` directly, without going through
|
||||
the API or the ingestion lock. Use it only when the AtoCore service
|
||||
itself is unhealthy and you can't hit the admin endpoint.
|
||||
|
||||
### Option C — manual file copy (last resort)
|
||||
|
||||
If both the API and the standalone script are unusable:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop atocore # or: docker compose stop atocore
|
||||
sudo cp /srv/storage/atocore/data/db/atocore.db \
|
||||
/srv/storage/atocore/backups/manual-$(date -u +%Y%m%dT%H%M%SZ).db
|
||||
sudo cp /srv/storage/atocore/config/project-registry.json \
|
||||
/srv/storage/atocore/backups/manual-$(date -u +%Y%m%dT%H%M%SZ).registry.json
|
||||
sudo systemctl start atocore
|
||||
```
|
||||
|
||||
This is a cold backup and requires brief downtime.
|
||||
|
||||
## Listing backups
|
||||
|
||||
```bash
|
||||
curl -fsS http://dalidou:8100/admin/backup
|
||||
```
|
||||
|
||||
Returns the configured `backup_dir` and a list of all snapshots
|
||||
under it, with their full metadata if available.
|
||||
|
||||
Or, on the host directly:
|
||||
|
||||
```bash
|
||||
ls -la /srv/storage/atocore/backups/snapshots/
|
||||
```
|
||||
|
||||
## Validating a backup
|
||||
|
||||
Before relying on a backup for restore, validate it:
|
||||
|
||||
```bash
|
||||
curl -fsS http://dalidou:8100/admin/backup/20260407T060000Z/validate
|
||||
```
|
||||
|
||||
The validator:
|
||||
- confirms the snapshot directory exists
|
||||
- opens the SQLite snapshot and runs `PRAGMA integrity_check`
|
||||
- parses the registry JSON
|
||||
- confirms the Chroma directory exists (if it was included)
|
||||
|
||||
A valid backup returns `"valid": true` and an empty `errors` array.
|
||||
A failing validation returns `"valid": false` with one or more
|
||||
specific error strings (e.g. `db_integrity_check_failed`,
|
||||
`registry_invalid_json`, `chroma_snapshot_missing`).
|
||||
|
||||
**Validate every backup at creation time.** A backup that has never
|
||||
been validated is not actually a backup — it's just a hopeful copy
|
||||
of bytes.
|
||||
|
||||
## Restore procedure
|
||||
|
||||
Since 2026-04-09 the restore is implemented as a proper module
|
||||
function plus CLI entry point: `restore_runtime_backup()` in
|
||||
`src/atocore/ops/backup.py`, invoked as
|
||||
`python -m atocore.ops.backup restore <STAMP> --confirm-service-stopped`.
|
||||
It automatically takes a pre-restore safety snapshot (your rollback
|
||||
anchor), handles SQLite WAL/SHM cleanly, restores the registry, and
|
||||
runs `PRAGMA integrity_check` on the restored db. This replaces the
|
||||
earlier manual `sudo cp` sequence.
|
||||
|
||||
The function refuses to run without `--confirm-service-stopped`.
|
||||
This is deliberate: hot-restoring into a running service corrupts
|
||||
SQLite state.
|
||||
|
||||
### Pre-flight (always)
|
||||
|
||||
1. Identify which snapshot you want to restore. List available
|
||||
snapshots and pick by timestamp:
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:8100/admin/backup | jq '.backups[].stamp'
|
||||
```
|
||||
2. Validate it. Refuse to restore an invalid backup:
|
||||
```bash
|
||||
STAMP=20260409T060000Z
|
||||
curl -fsS http://127.0.0.1:8100/admin/backup/$STAMP/validate | jq .
|
||||
```
|
||||
3. **Stop AtoCore.** SQLite cannot be hot-restored under a running
|
||||
process and Chroma will not pick up new files until the process
|
||||
restarts.
|
||||
```bash
|
||||
cd /srv/storage/atocore/app/deploy/dalidou
|
||||
docker compose down
|
||||
docker compose ps # atocore should be Exited/gone
|
||||
```
|
||||
|
||||
### Run the restore
|
||||
|
||||
Use a one-shot container that reuses the live service's volume
|
||||
mounts so every path (`db_path`, `chroma_path`, backup dir) resolves
|
||||
to the same place the main service would see:
|
||||
|
||||
```bash
|
||||
cd /srv/storage/atocore/app/deploy/dalidou
|
||||
docker compose run --rm --entrypoint python atocore \
|
||||
-m atocore.ops.backup restore \
|
||||
$STAMP \
|
||||
--confirm-service-stopped
|
||||
```
|
||||
|
||||
Output is a JSON document. The critical fields:
|
||||
|
||||
- `pre_restore_snapshot`: stamp of the safety snapshot of live
|
||||
state taken right before the restore. **Write this down.** If
|
||||
the restore was the wrong call, this is how you roll it back.
|
||||
- `db_restored`: should be `true`
|
||||
- `registry_restored`: `true` if the backup captured a registry
|
||||
- `chroma_restored`: `true` if the backup captured a chroma tree
|
||||
and include_chroma resolved to true (default)
|
||||
- `restored_integrity_ok`: **must be `true`** — if this is false,
|
||||
STOP and do not start the service; investigate the integrity
|
||||
error first. The restored file is still on disk but untrusted.
|
||||
|
||||
### Controlling the restore
|
||||
|
||||
The CLI supports a few flags for finer control:
|
||||
|
||||
- `--no-pre-snapshot` skips the pre-restore safety snapshot. Use
|
||||
this only when you know you have another rollback path.
|
||||
- `--no-chroma` restores only SQLite + registry, leaving the
|
||||
current Chroma dir alone. Useful if Chroma is consistent but
|
||||
SQLite needs a rollback.
|
||||
- `--chroma` forces Chroma restoration even if the metadata
|
||||
doesn't clearly indicate the snapshot has it (rare).
|
||||
|
||||
### Chroma restore and bind-mounted volumes
|
||||
|
||||
The Chroma dir on Dalidou is a bind-mounted Docker volume. The
|
||||
restore cannot `rmtree` the destination (you can't unlink a mount
|
||||
point — it raises `OSError [Errno 16] Device or resource busy`),
|
||||
so the function clears the dir's CONTENTS and uses
|
||||
`copytree(dirs_exist_ok=True)` to copy the snapshot back in. The
|
||||
regression test `test_restore_chroma_does_not_unlink_destination_directory`
|
||||
in `tests/test_backup.py` captures the destination inode before
|
||||
and after restore and asserts it's stable — the same invariant
|
||||
that protects the bind mount.
|
||||
|
||||
This was discovered during the first real Dalidou restore drill
|
||||
on 2026-04-09. If you see a new restore failure with
|
||||
`Device or resource busy`, something has regressed this fix.
|
||||
|
||||
### Restart AtoCore
|
||||
|
||||
```bash
|
||||
cd /srv/storage/atocore/app/deploy/dalidou
|
||||
docker compose up -d
|
||||
# Wait for /health to come up
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
curl -fsS http://127.0.0.1:8100/health \
|
||||
&& break || { echo "not ready ($i/10)"; sleep 3; }
|
||||
done
|
||||
```
|
||||
|
||||
**Note on build_sha after restore:** The one-shot `docker compose run`
|
||||
container does not carry the build provenance env vars that `deploy.sh`
|
||||
exports at deploy time. After a restore, `/health` will report
|
||||
`build_sha: "unknown"` until you re-run `deploy.sh` or manually
|
||||
re-deploy. This is cosmetic — the data is correctly restored — but if
|
||||
you need `build_sha` to be accurate, run a redeploy after the restore:
|
||||
|
||||
```bash
|
||||
cd /srv/storage/atocore/app
|
||||
bash deploy/dalidou/deploy.sh
|
||||
```
|
||||
|
||||
### Post-restore verification
|
||||
|
||||
```bash
|
||||
# 1. Service is healthy
|
||||
curl -fsS http://127.0.0.1:8100/health | jq .
|
||||
|
||||
# 2. Stats look right
|
||||
curl -fsS http://127.0.0.1:8100/stats | jq .
|
||||
|
||||
# 3. Project registry loads
|
||||
curl -fsS http://127.0.0.1:8100/projects | jq '.projects | length'
|
||||
|
||||
# 4. A known-good context query returns non-empty results
|
||||
curl -fsS -X POST http://127.0.0.1:8100/context/build \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"prompt": "what is p05 about", "project": "p05-interferometer"}' | jq '.chunks_used'
|
||||
```
|
||||
|
||||
If any of these are wrong, the restore is bad. Roll back using the
|
||||
pre-restore safety snapshot whose stamp you recorded from the
|
||||
restore output. The rollback is the same procedure — stop the
|
||||
service and restore that stamp:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose run --rm --entrypoint python atocore \
|
||||
-m atocore.ops.backup restore \
|
||||
$PRE_RESTORE_SNAPSHOT_STAMP \
|
||||
--confirm-service-stopped \
|
||||
--no-pre-snapshot
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
(`--no-pre-snapshot` because the rollback itself doesn't need one;
|
||||
you already have the original snapshot as a fallback if everything
|
||||
goes sideways.)
|
||||
|
||||
### Restore drill
|
||||
|
||||
The restore is exercised at three levels:
|
||||
|
||||
1. **Unit tests.** `tests/test_backup.py` has six restore tests
|
||||
(refuse-without-confirm, invalid backup, full round-trip,
|
||||
Chroma round-trip, inode-stability regression, WAL sidecar
|
||||
cleanup, skip-pre-snapshot). These run in CI on every commit.
|
||||
2. **Module-level round-trip.**
|
||||
`test_restore_round_trip_reverses_post_backup_mutations` is
|
||||
the canonical drill in code form: seed baseline, snapshot,
|
||||
mutate, restore, assert mutation reversed + baseline survived
|
||||
+ pre-restore snapshot captured the mutation.
|
||||
3. **Live drill on Dalidou.** Periodically run the full procedure
|
||||
against the real service with a disposable drill-marker
|
||||
memory (created via `POST /memory` with `memory_type=episodic`
|
||||
and `project=drill`), following the sequence above and then
|
||||
verifying the marker is gone afterward via
|
||||
`GET /memory?project=drill`. The first such drill on
|
||||
2026-04-09 surfaced the bind-mount bug; future runs
|
||||
primarily exist to verify the fix stays fixed.
|
||||
|
||||
Run the live drill:
|
||||
|
||||
- **Before** enabling any new write-path automation (auto-capture,
|
||||
automated ingestion, reinforcement sweeps).
|
||||
- **After** any change to `src/atocore/ops/backup.py` or to
|
||||
schema migrations in `src/atocore/models/database.py`.
|
||||
- **After** a Dalidou OS upgrade or docker version bump.
|
||||
- **At least once per quarter** as a standing operational check.
|
||||
- **After any incident** that touched the storage layer.
|
||||
|
||||
Record each drill run (stamp, pre-restore snapshot stamp, pass/fail,
|
||||
any surprises) somewhere durable — a line in the project journal
|
||||
or a git commit message is enough. A drill you ran once and never
|
||||
again is barely more than a drill you never ran.
|
||||
|
||||
## Retention policy
|
||||
|
||||
- **Last 7 daily backups**: kept verbatim
|
||||
- **Last 4 weekly backups** (Sunday): kept verbatim
|
||||
- **Last 6 monthly backups** (1st of month): kept verbatim
|
||||
- **Anything older**: deleted
|
||||
|
||||
The retention job is **not yet implemented** and is tracked as a
|
||||
follow-up. Until then, the snapshots directory grows monotonically.
|
||||
A simple cron-based cleanup script is the next step:
|
||||
|
||||
```cron
|
||||
0 4 * * * /srv/storage/atocore/scripts/cleanup-old-backups.sh
|
||||
```
|
||||
|
||||
## Common failure modes and what to do about them
|
||||
|
||||
| Symptom | Likely cause | Action |
|
||||
|---|---|---|
|
||||
| `db_integrity_check_failed` on validation | SQLite snapshot copied while a write was in progress, or disk corruption | Take a fresh backup and validate again. If it fails twice, suspect the underlying disk. |
|
||||
| `registry_invalid_json` | Registry was being edited at backup time | Take a fresh backup. The registry is small so this is cheap. |
|
||||
| Restore: `restored_integrity_ok: false` | Source snapshot was itself corrupt (validation should have caught it — file a bug) or copy was interrupted mid-write | Do NOT start the service. Validate the snapshot directly with `python -m atocore.ops.backup validate <STAMP>`, try a different older snapshot, or roll back to the pre-restore safety snapshot. |
|
||||
| Restore: `OSError [Errno 16] Device or resource busy` on Chroma | Old code tried to `rmtree` the Chroma mount point. Fixed on 2026-04-09 by `test_restore_chroma_does_not_unlink_destination_directory` | Ensure you're running commit 2026-04-09 or later; if you need to work around an older build, use `--no-chroma` and restore Chroma contents manually. |
|
||||
| `chroma_snapshot_missing` after a restore | Snapshot was DB-only | Either rebuild via fresh ingestion or restore an older snapshot that includes Chroma. |
|
||||
| Service won't start after restore | Permissions wrong on the restored files | Re-run `chown 1000:1000` (or whatever the gitea/atocore container user is) on the data dir. |
|
||||
| `/stats` returns 0 documents after restore | The SQL store was restored but the source paths in `source_documents` don't match the current Dalidou paths | This means the backup came from a different deployment. Don't trust this restore — it's pulling from the wrong layout. |
|
||||
| Drill marker still present after restore | Wrong stamp, service still writing during `docker compose down`, or the restore JSON didn't report `db_restored: true` | Roll back via the pre-restore safety snapshot and retry with the correct source snapshot. |
|
||||
|
||||
## Open follow-ups (not yet implemented)
|
||||
|
||||
Tracked separately in `docs/next-steps.md` — the list below is the
|
||||
backup-specific subset.
|
||||
|
||||
1. **Retention cleanup script**: see the cron entry above. The
|
||||
snapshots directory grows monotonically until this exists.
|
||||
2. **Off-Dalidou backup target**: currently snapshots live on the
|
||||
same disk as the live data. A real disaster-recovery story
|
||||
needs at least one snapshot on a different physical machine.
|
||||
The simplest first step is a periodic `rsync` to the user's
|
||||
laptop or to another server.
|
||||
3. **Backup encryption**: snapshots contain raw SQLite and JSON.
|
||||
Consider age/gpg encryption if backups will be shipped off-site.
|
||||
4. **Automatic post-backup validation**: today the validator must
|
||||
be invoked manually. The `create_runtime_backup` function
|
||||
should call `validate_backup` on its own output and refuse to
|
||||
declare success if validation fails.
|
||||
5. **Chroma backup is currently full directory copy** every time.
|
||||
For large vector stores this gets expensive. A future
|
||||
improvement would be incremental snapshots via filesystem-level
|
||||
snapshotting (LVM, btrfs, ZFS).
|
||||
|
||||
**Done** (kept for historical reference):
|
||||
|
||||
- ~~Implement `restore_runtime_backup()` as a proper module
|
||||
function so the restore isn't a manual `sudo cp` dance~~ —
|
||||
landed 2026-04-09 in commit 3362080, followed by the
|
||||
Chroma bind-mount fix from the first real drill.
|
||||
|
||||
## Quickstart cheat sheet
|
||||
|
||||
```bash
|
||||
# Daily backup (DB + registry only — fast)
|
||||
curl -fsS -X POST http://127.0.0.1:8100/admin/backup \
|
||||
-H "Content-Type: application/json" -d '{}'
|
||||
|
||||
# Weekly backup (DB + registry + Chroma — slower, holds ingestion lock)
|
||||
curl -fsS -X POST http://127.0.0.1:8100/admin/backup \
|
||||
-H "Content-Type: application/json" -d '{"include_chroma": true}'
|
||||
|
||||
# List backups
|
||||
curl -fsS http://127.0.0.1:8100/admin/backup | jq '.backups[].stamp'
|
||||
|
||||
# Validate the most recent backup
|
||||
LATEST=$(curl -fsS http://127.0.0.1:8100/admin/backup | jq -r '.backups[-1].stamp')
|
||||
curl -fsS http://127.0.0.1:8100/admin/backup/$LATEST/validate | jq .
|
||||
|
||||
# Full restore (service must be stopped first)
|
||||
cd /srv/storage/atocore/app/deploy/dalidou
|
||||
docker compose down
|
||||
docker compose run --rm --entrypoint python atocore \
|
||||
-m atocore.ops.backup restore $STAMP --confirm-service-stopped
|
||||
docker compose up -d
|
||||
|
||||
# Live drill: exercise the full create -> mutate -> restore flow
|
||||
# against the running service. The marker memory uses
|
||||
# memory_type=episodic (valid types: identity, preference, project,
|
||||
# episodic, knowledge, adaptation) and project=drill so it's easy
|
||||
# to find via GET /memory?project=drill before and after.
|
||||
#
|
||||
# See the "Restore drill" section above for the full sequence.
|
||||
STAMP=$(curl -fsS -X POST http://127.0.0.1:8100/admin/backup \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"include_chroma": true}' | jq -r '.backup_root' | awk -F/ '{print $NF}')
|
||||
|
||||
curl -fsS -X POST http://127.0.0.1:8100/memory \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"memory_type":"episodic","content":"DRILL-MARKER","project":"drill","confidence":1.0}'
|
||||
|
||||
cd /srv/storage/atocore/app/deploy/dalidou
|
||||
docker compose down
|
||||
docker compose run --rm --entrypoint python atocore \
|
||||
-m atocore.ops.backup restore $STAMP --confirm-service-stopped
|
||||
docker compose up -d
|
||||
|
||||
# Marker should be gone:
|
||||
curl -fsS 'http://127.0.0.1:8100/memory?project=drill' | jq .
|
||||
```
|
||||
45
docs/capture-surfaces.md
Normal file
45
docs/capture-surfaces.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# AtoCore — sanctioned capture surfaces
|
||||
|
||||
**Scope statement**: AtoCore captures conversations from **two surfaces only**. Everything else is intentionally out of scope.
|
||||
|
||||
| Surface | Hooks | Status |
|
||||
|---|---|---|
|
||||
| **Claude Code** (local CLI) | `Stop` (capture) + `UserPromptSubmit` (context injection) | both installed |
|
||||
| **OpenClaw** (agent framework on T420) | `before_agent_start` (context injection) + `llm_output` (capture) | both installed (v0.2.0 plugin, Phase 7I) |
|
||||
|
||||
Both surfaces are **symmetric** — push (capture) and pull (context injection on prompt submit) — so AtoCore learns from every turn AND every turn is grounded in what AtoCore already knows.
|
||||
|
||||
## Why these two?
|
||||
|
||||
- **Stable hook APIs.** Claude Code exposes `Stop` and `UserPromptSubmit` lifecycle hooks with documented JSON contracts. OpenClaw exposes `before_agent_start` and `llm_output`. Both run locally where we control the process.
|
||||
- **Passive from the user's perspective.** No paste, no manual capture command, no "remember this" prompt. You just use the tool and AtoCore absorbs everything durable.
|
||||
- **Failure is graceful.** If AtoCore is down, hooks exit 0 with no output — the user's turn proceeds uninterrupted.
|
||||
|
||||
## Why not Claude Desktop / Claude.ai web / Claude mobile / ChatGPT / …?
|
||||
|
||||
- Claude Desktop has MCP but no `Stop`-equivalent hook for auto-capture; auto-capture would require system-prompt coercion ("call atocore_remember every turn"), which is fragile.
|
||||
- Claude.ai web has no hook surface — would need a browser extension (real project, not shipped).
|
||||
- Claude mobile app has neither hooks nor MCP — nothing to wire into.
|
||||
- ChatGPT etc. — same as above.
|
||||
|
||||
**Anthropic API log polling is explicitly prohibited.**
|
||||
|
||||
If you find yourself wanting to capture from one of these, the real answer is: use Claude Code or OpenClaw for the work that matters. Don't paste chat transcripts into AtoCore — that contradicts the whole design principle of passive capture.
|
||||
|
||||
A `/wiki/capture` fallback form still exists (the endpoint `/interactions` is public) but it is **not promoted in the UI** and is documented as a last-resort escape hatch. If you're reaching for it, something is wrong with your workflow, not with AtoCore.
|
||||
|
||||
## Hook files
|
||||
|
||||
- `deploy/hooks/capture_stop.py` — Claude Code Stop → POSTs `/interactions`
|
||||
- `deploy/hooks/inject_context.py` — Claude Code UserPromptSubmit → POSTs `/context/build`, returns pack via `hookSpecificOutput.additionalContext`
|
||||
- `openclaw-plugins/atocore-capture/index.js` — OpenClaw plugin v0.2.0: capture + context injection
|
||||
|
||||
Both Claude Code hooks share a `_infer_project` table mapping cwd to project slug. Keep them in sync when adding a new project path.
|
||||
|
||||
## Kill switches
|
||||
|
||||
- `ATOCORE_CAPTURE_DISABLED=1` → skip Stop capture
|
||||
- `ATOCORE_CONTEXT_DISABLED=1` → skip UserPromptSubmit injection
|
||||
- OpenClaw plugin config `injectContext: false` → skip context injection (capture still fires)
|
||||
|
||||
All three are documented in the respective hook/plugin files.
|
||||
@@ -1,247 +1,49 @@
|
||||
# AtoCore Current State
|
||||
# AtoCore — Current State (2026-04-19)
|
||||
|
||||
## Status Summary
|
||||
Live deploy: `877b97e` · Dalidou health: ok · Harness: 17/18.
|
||||
|
||||
AtoCore is no longer just a proof of concept. The local engine exists, the
|
||||
correctness pass is complete, Dalidou now hosts the canonical runtime and
|
||||
machine-storage location, and the T420/OpenClaw side now has a safe read-only
|
||||
path to consume AtoCore. The live corpus is no longer just self-knowledge: it
|
||||
now includes a first curated ingestion batch for the active projects.
|
||||
## The numbers
|
||||
|
||||
## Phase Assessment
|
||||
| | count |
|
||||
|---|---|
|
||||
| Active memories | 266 (180 project, 31 preference, 24 knowledge, 17 adaptation, 11 episodic, 3 identity) |
|
||||
| Candidates pending | **0** (autonomous triage drained the queue) |
|
||||
| Interactions captured | 605 (250 claude-code, 351 openclaw) |
|
||||
| Entities (typed graph) | 50 |
|
||||
| Vectors in Chroma | 33K+ |
|
||||
| Projects | 6 registered (p04, p05, p06, abb-space, atomizer-v2, atocore) + apm emerging (2 memories, below auto-register threshold) |
|
||||
| Unique domain tags | 210 |
|
||||
| Tests | 440 passing |
|
||||
|
||||
- completed
|
||||
- Phase 0
|
||||
- Phase 0.5
|
||||
- Phase 1
|
||||
- baseline complete
|
||||
- Phase 2
|
||||
- Phase 3
|
||||
- Phase 5
|
||||
- Phase 7
|
||||
- Phase 9 (Commits A/B/C: capture, reinforcement, extractor + review queue)
|
||||
- partial
|
||||
- Phase 4
|
||||
- Phase 8
|
||||
- not started
|
||||
- Phase 6
|
||||
- Phase 10
|
||||
- Phase 11
|
||||
- Phase 12
|
||||
- Phase 13
|
||||
## Autonomous pipeline — what runs without me
|
||||
|
||||
## What Exists Today
|
||||
| When | Job | Does |
|
||||
|---|---|---|
|
||||
| every hour | `hourly-extract.sh` | Pulls new interactions → LLM extraction → 3-tier auto-triage (sonnet → opus → discard/human). 0 pending candidates right now = autonomy is working. |
|
||||
| every 2 min | `dedup-watcher.sh` | Services UI-triggered dedup scans |
|
||||
| daily 03:00 UTC | Full nightly (`batch-extract.sh`) | Extract · triage · auto-promote reinforced · synthesis · harness · dedup (0.90) · emerging detector · transient→durable · **confidence decay (7D)** · integrity check · alerts |
|
||||
| Sundays | +Weekly deep pass | Knowledge-base lint · dedup @ 0.85 · **tag canonicalization (7C)** |
|
||||
|
||||
- ingestion pipeline
|
||||
- parser and chunker
|
||||
- SQLite-backed memory and project state
|
||||
- vector retrieval
|
||||
- context builder
|
||||
- API routes for query, context, health, and source status
|
||||
- project registry and per-project refresh foundation
|
||||
- project registration lifecycle:
|
||||
- template
|
||||
- proposal preview
|
||||
- approved registration
|
||||
- safe update of existing project registrations
|
||||
- refresh
|
||||
- implementation-facing architecture notes for:
|
||||
- engineering knowledge hybrid architecture
|
||||
- engineering ontology v1
|
||||
- env-driven storage and deployment paths
|
||||
- Dalidou Docker deployment foundation
|
||||
- initial AtoCore self-knowledge corpus ingested on Dalidou
|
||||
- T420/OpenClaw read-only AtoCore helper skill
|
||||
- full active-project markdown/text corpus wave for:
|
||||
- `p04-gigabit`
|
||||
- `p05-interferometer`
|
||||
- `p06-polisher`
|
||||
Last nightly run (2026-04-19 03:00 UTC): **31 promoted · 39 rejected · 0 needs human**. That's the brain self-organizing.
|
||||
|
||||
## What Is True On Dalidou
|
||||
## Phase 7 — Memory Consolidation status
|
||||
|
||||
- deployed repo location:
|
||||
- `/srv/storage/atocore/app`
|
||||
- canonical machine DB location:
|
||||
- `/srv/storage/atocore/data/db/atocore.db`
|
||||
- canonical vector store location:
|
||||
- `/srv/storage/atocore/data/chroma`
|
||||
- source input locations:
|
||||
- `/srv/storage/atocore/sources/vault`
|
||||
- `/srv/storage/atocore/sources/drive`
|
||||
| Subphase | What | Status |
|
||||
|---|---|---|
|
||||
| 7A | Semantic dedup + merge lifecycle | live |
|
||||
| 7A.1 | Tiered auto-approve (sonnet ≥0.8 + sim ≥0.92 → merge; opus escalation; human only for ambiguous) | live |
|
||||
| 7B | Memory-to-memory contradiction detection (0.70–0.88 band, classify duplicate/contradicts/supersedes) | deferred, needs 7A signal |
|
||||
| 7C | Tag canonicalization (weekly; auto-apply ≥0.8 confidence; protects project tokens) | live (first run: 0 proposals — vocabulary is clean) |
|
||||
| 7D | Confidence decay (0.97/day on idle unreferenced; auto-supersede below 0.3) | live (first run: 0 decayed — nothing idle+unreferenced yet) |
|
||||
| 7E | `/wiki/memories/{id}` detail page | pending |
|
||||
| 7F | `/wiki/domains/{tag}` cross-project view | pending (wants 7C + more usage first) |
|
||||
| 7G | Re-extraction on prompt version bump | pending |
|
||||
| 7H | Chroma vector hygiene (delete vectors for superseded memories) | pending |
|
||||
|
||||
The service and storage foundation are live on Dalidou.
|
||||
## Known gaps (honest)
|
||||
|
||||
The machine-data host is real and canonical.
|
||||
|
||||
The project registry is now also persisted in a canonical mounted config path on
|
||||
Dalidou:
|
||||
|
||||
- `/srv/storage/atocore/config/project-registry.json`
|
||||
|
||||
The content corpus is partially populated now.
|
||||
|
||||
The Dalidou instance already contains:
|
||||
|
||||
- AtoCore ecosystem and hosting docs
|
||||
- current-state and OpenClaw integration docs
|
||||
- Master Plan V3
|
||||
- Build Spec V1
|
||||
- trusted project-state entries for `atocore`
|
||||
- full staged project markdown/text corpora for:
|
||||
- `p04-gigabit`
|
||||
- `p05-interferometer`
|
||||
- `p06-polisher`
|
||||
- curated repo-context docs for:
|
||||
- `p05`: `Fullum-Interferometer`
|
||||
- `p06`: `polisher-sim`
|
||||
- trusted project-state entries for:
|
||||
- `p04-gigabit`
|
||||
- `p05-interferometer`
|
||||
- `p06-polisher`
|
||||
|
||||
Current live stats after the full active-project wave are now far beyond the
|
||||
initial seed stage:
|
||||
|
||||
- more than `1,100` source documents
|
||||
- more than `20,000` chunks
|
||||
- matching vector count
|
||||
|
||||
The broader long-term corpus is still not fully populated yet. Wider project and
|
||||
vault ingestion remains a deliberate next step rather than something already
|
||||
completed, but the corpus is now meaningfully seeded beyond AtoCore's own docs.
|
||||
|
||||
For human-readable quality review, the current staged project markdown corpus is
|
||||
primarily visible under:
|
||||
|
||||
- `/srv/storage/atocore/sources/vault/incoming/projects`
|
||||
|
||||
This staged area is now useful for review because it contains the markdown/text
|
||||
project docs that were actually ingested for the full active-project wave.
|
||||
|
||||
It is important to read this staged area correctly:
|
||||
|
||||
- it is a readable ingestion input layer
|
||||
- it is not the final machine-memory representation itself
|
||||
- seeing familiar PKM-style notes there is expected
|
||||
- the machine-processed intelligence lives in the DB, chunks, vectors, memory,
|
||||
trusted project state, and context-builder outputs
|
||||
|
||||
## What Is True On The T420
|
||||
|
||||
- SSH access is working
|
||||
- OpenClaw workspace inspected at `/home/papa/clawd`
|
||||
- OpenClaw's own memory system remains unchanged
|
||||
- a read-only AtoCore integration skill exists in the workspace:
|
||||
- `/home/papa/clawd/skills/atocore-context/`
|
||||
- the T420 can successfully reach Dalidou AtoCore over network/Tailscale
|
||||
- fail-open behavior has been verified for the helper path
|
||||
- OpenClaw can now seed AtoCore in two distinct ways:
|
||||
- project-scoped memory entries
|
||||
- staged document ingestion into the retrieval corpus
|
||||
- the helper now supports the practical registered-project lifecycle:
|
||||
- projects
|
||||
- project-template
|
||||
- propose-project
|
||||
- register-project
|
||||
- update-project
|
||||
- refresh-project
|
||||
- the helper now also supports the first organic routing layer:
|
||||
- `detect-project "<prompt>"`
|
||||
- `auto-context "<prompt>" [budget] [project]`
|
||||
- OpenClaw can now default to AtoCore for project-knowledge questions without
|
||||
requiring explicit helper commands from the human every time
|
||||
|
||||
## What Exists In Memory vs Corpus
|
||||
|
||||
These remain separate and that is intentional.
|
||||
|
||||
In `/memory`:
|
||||
|
||||
- project-scoped curated memories now exist for:
|
||||
- `p04-gigabit`: 5 memories
|
||||
- `p05-interferometer`: 6 memories
|
||||
- `p06-polisher`: 8 memories
|
||||
|
||||
These are curated summaries and extracted stable project signals.
|
||||
|
||||
In `source_documents` / retrieval corpus:
|
||||
|
||||
- full project markdown/text corpora are now present for the active project set
|
||||
- retrieval is no longer limited to AtoCore self-knowledge only
|
||||
- the current corpus is broad enough that ranking quality matters more than
|
||||
corpus presence alone
|
||||
- underspecified prompts can still pull in historical or archive material, so
|
||||
project-aware routing and better ranking remain important
|
||||
|
||||
The source refresh model now has a concrete foundation in code:
|
||||
|
||||
- a project registry file defines known project ids, aliases, and ingest roots
|
||||
- the API can list registered projects
|
||||
- the API can return a registration template
|
||||
- the API can preview a registration without mutating state
|
||||
- the API can persist an approved registration
|
||||
- the API can update an existing registered project without changing its canonical id
|
||||
- the API can refresh one registered project at a time
|
||||
|
||||
This lifecycle is now coherent end to end for normal use.
|
||||
|
||||
The first live update passes on existing registered projects have now been
|
||||
verified against `p04-gigabit` and `p05-interferometer`:
|
||||
|
||||
- the registration description can be updated safely
|
||||
- the canonical project id remains unchanged
|
||||
- refresh still behaves cleanly after the update
|
||||
- `context/build` still returns useful project-specific context afterward
|
||||
|
||||
## Reliability Baseline
|
||||
|
||||
The runtime has now been hardened in a few practical ways:
|
||||
|
||||
- SQLite connections use a configurable busy timeout
|
||||
- SQLite uses WAL mode to reduce transient lock pain under normal concurrent use
|
||||
- project registry writes are atomic file replacements rather than in-place rewrites
|
||||
- a first runtime backup path now exists for:
|
||||
- SQLite
|
||||
- project registry
|
||||
- backup metadata
|
||||
|
||||
This does not eliminate every concurrency edge, but it materially improves the
|
||||
current operational baseline.
|
||||
|
||||
In `Trusted Project State`:
|
||||
|
||||
- each active seeded project now has a conservative trusted-state set
|
||||
- promoted facts cover:
|
||||
- summary
|
||||
- core architecture or boundary decision
|
||||
- key constraints
|
||||
- next focus
|
||||
|
||||
This separation is healthy:
|
||||
|
||||
- memory stores distilled project facts
|
||||
- corpus stores the underlying retrievable documents
|
||||
|
||||
## Immediate Next Focus
|
||||
|
||||
1. Use the new T420-side organic routing layer in real OpenClaw workflows
|
||||
2. Tighten retrieval quality for the now fully ingested active project corpora
|
||||
3. Move to Wave 2 trusted-operational ingestion instead of blindly widening raw corpus further
|
||||
4. Keep the new engineering-knowledge architecture docs as implementation guidance while avoiding premature schema work
|
||||
5. Expand the boring operations baseline:
|
||||
- restore validation
|
||||
- Chroma rebuild / backup policy
|
||||
- retention
|
||||
6. Only later consider write-back, reflection, or deeper autonomous behaviors
|
||||
|
||||
See also:
|
||||
|
||||
- [ingestion-waves.md](C:/Users/antoi/ATOCore/docs/ingestion-waves.md)
|
||||
- [master-plan-status.md](C:/Users/antoi/ATOCore/docs/master-plan-status.md)
|
||||
|
||||
## Guiding Constraints
|
||||
|
||||
- bad memory is worse than no memory
|
||||
- trusted project state must remain highest priority
|
||||
- human-readable sources and machine storage stay separate
|
||||
- OpenClaw integration must not degrade OpenClaw baseline behavior
|
||||
1. **Capture surface is Claude-Code-and-OpenClaw only.** Conversations in Claude Desktop, Claude.ai web, phone, or any other LLM UI are NOT captured. Example: the rotovap/mushroom chat yesterday never reached AtoCore because no hook fired. See Q4 below.
|
||||
2. **OpenClaw is capture-only, not context-grounded.** The plugin POSTs `/interactions` on `llm_output` but does NOT call `/context/build` on `before_agent_start`. OpenClaw's underlying agent runs blind. See Q2 below.
|
||||
3. **Human interface (wiki) is thin and static.** 5 project cards + a "System" line. No dashboard for the autonomous activity. No per-memory detail page. See Q3/Q5.
|
||||
4. **Harness 17/18** — the `p04-constraints` fixture wants "Zerodur" but retrieval surfaces related-not-exact terms. Content gap, not a retrieval regression.
|
||||
5. **Two projects under-populated**: p05-interferometer (4 memories, 18 state) and atomizer-v2 (1 memory, 6 state). Batch re-extract with the new llm-0.6.0 prompt would help.
|
||||
|
||||
@@ -50,26 +50,205 @@ starting from:
|
||||
deploy/dalidou/.env.example
|
||||
```
|
||||
|
||||
## Deployment steps
|
||||
## First-time deployment steps
|
||||
|
||||
1. Place the repository under `/srv/storage/atocore/app` — ideally as a
|
||||
proper git clone so future updates can be pulled, not as a static
|
||||
snapshot:
|
||||
|
||||
```bash
|
||||
sudo git clone http://dalidou:3000/Antoine/ATOCore.git \
|
||||
/srv/storage/atocore/app
|
||||
```
|
||||
|
||||
1. Place the repository under `/srv/storage/atocore/app`.
|
||||
2. Create the canonical directories listed above.
|
||||
3. Copy `deploy/dalidou/.env.example` to `deploy/dalidou/.env`.
|
||||
4. Adjust the source paths if your AtoVault/AtoDrive mirrors live elsewhere.
|
||||
5. Run:
|
||||
|
||||
```bash
|
||||
cd /srv/storage/atocore/app/deploy/dalidou
|
||||
docker compose up -d --build
|
||||
```
|
||||
```bash
|
||||
cd /srv/storage/atocore/app/deploy/dalidou
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
6. Validate:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8100/health
|
||||
curl http://127.0.0.1:8100/sources
|
||||
```
|
||||
|
||||
## Updating a running deployment
|
||||
|
||||
**Use `deploy/dalidou/deploy.sh` for every code update.** It is the
|
||||
one-shot sync script that:
|
||||
|
||||
- fetches latest main from Gitea into `/srv/storage/atocore/app`
|
||||
- (if the app dir is not a git checkout) backs it up as
|
||||
`<dir>.pre-git-<timestamp>` and re-clones
|
||||
- rebuilds the container image
|
||||
- restarts the container
|
||||
- waits for `/health` to respond
|
||||
- compares the reported `code_version` against the
|
||||
`__version__` in the freshly-pulled source, and exits non-zero
|
||||
if they don't match (deployment drift detection)
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8100/health
|
||||
curl http://127.0.0.1:8100/sources
|
||||
# Normal update from main
|
||||
bash /srv/storage/atocore/app/deploy/dalidou/deploy.sh
|
||||
|
||||
# Deploy a specific branch or tag
|
||||
ATOCORE_BRANCH=codex/some-feature \
|
||||
bash /srv/storage/atocore/app/deploy/dalidou/deploy.sh
|
||||
|
||||
# Dry-run: show what would happen without touching anything
|
||||
ATOCORE_DEPLOY_DRY_RUN=1 \
|
||||
bash /srv/storage/atocore/app/deploy/dalidou/deploy.sh
|
||||
|
||||
# Deploy from a remote host (e.g. the laptop) using the Tailscale
|
||||
# or LAN address instead of loopback
|
||||
ATOCORE_GIT_REMOTE=http://192.168.86.50:3000/Antoine/ATOCore.git \
|
||||
bash /srv/storage/atocore/app/deploy/dalidou/deploy.sh
|
||||
```
|
||||
|
||||
The script is idempotent and safe to re-run. It never touches the
|
||||
database directly — schema migrations are applied automatically at
|
||||
service startup by the lifespan handler in `src/atocore/main.py`
|
||||
which calls `init_db()` (which in turn runs the ALTER TABLE
|
||||
statements in `_apply_migrations`).
|
||||
|
||||
### Troubleshooting hostname resolution
|
||||
|
||||
`deploy.sh` defaults `ATOCORE_GIT_REMOTE` to
|
||||
`http://127.0.0.1:3000/Antoine/ATOCore.git` (loopback) because the
|
||||
hostname "dalidou" doesn't reliably resolve on the host itself —
|
||||
the first real Dalidou deploy hit exactly this on 2026-04-08. If
|
||||
you need to override (e.g. running deploy.sh from a laptop against
|
||||
the Dalidou LAN), set `ATOCORE_GIT_REMOTE` explicitly.
|
||||
|
||||
The same applies to `scripts/atocore_client.py`: its default
|
||||
`ATOCORE_BASE_URL` is `http://dalidou:8100` for remote callers, but
|
||||
when running the client on Dalidou itself (or inside the container
|
||||
via `docker exec`), override to loopback:
|
||||
|
||||
```bash
|
||||
ATOCORE_BASE_URL=http://127.0.0.1:8100 \
|
||||
python scripts/atocore_client.py health
|
||||
```
|
||||
|
||||
If you see `{"status": "unavailable", "fail_open": true}` from the
|
||||
client, the first thing to check is whether the base URL resolves
|
||||
from where you're running the client.
|
||||
|
||||
### The deploy.sh self-update race
|
||||
|
||||
When `deploy.sh` itself changes in the commit being pulled, the
|
||||
first run after the update is still executing the *old* script from
|
||||
the bash process's in-memory copy. `git reset --hard` updates the
|
||||
file on disk, but the running bash has already loaded the
|
||||
instructions. On 2026-04-09 this silently shipped an "unknown"
|
||||
`build_sha` because the old Step 2 (which predated env-var export)
|
||||
ran against fresh source.
|
||||
|
||||
`deploy.sh` now detects this: Step 1.5 compares the sha1 of `$0`
|
||||
(the running script) against the sha1 of
|
||||
`$APP_DIR/deploy/dalidou/deploy.sh` (the on-disk copy) after the
|
||||
git reset. If they differ, it sets `ATOCORE_DEPLOY_REEXECED=1` and
|
||||
`exec`s the fresh copy so the rest of the deploy runs under the new
|
||||
script. The sentinel env var prevents infinite recursion.
|
||||
|
||||
You'll see this in the logs as:
|
||||
|
||||
```text
|
||||
==> Step 1.5: deploy.sh changed in the pulled commit; re-exec'ing
|
||||
==> running script hash: <old>
|
||||
==> on-disk script hash: <new>
|
||||
==> re-exec -> /srv/storage/atocore/app/deploy/dalidou/deploy.sh
|
||||
```
|
||||
|
||||
To opt out (debugging, for example), pre-set
|
||||
`ATOCORE_DEPLOY_REEXECED=1` before invoking `deploy.sh` and the
|
||||
self-update guard will be skipped.
|
||||
|
||||
### Deployment drift detection
|
||||
|
||||
`/health` reports drift signals at three increasing levels of
|
||||
precision:
|
||||
|
||||
| Field | Source | Precision | When to use |
|
||||
|---|---|---|---|
|
||||
| `version` / `code_version` | `atocore.__version__` (manual bump) | coarse — same value across many commits | quick smoke check that the right *release* is running |
|
||||
| `build_sha` | `ATOCORE_BUILD_SHA` env var, set by `deploy.sh` per build | precise — changes per commit | the canonical drift signal |
|
||||
| `build_time` / `build_branch` | same env var path | per-build | forensics when multiple branches in flight |
|
||||
|
||||
The **precise** check (run on the laptop or any host that can curl
|
||||
the live service AND has the source repo at hand):
|
||||
|
||||
```bash
|
||||
# What's actually running on Dalidou
|
||||
LIVE_SHA=$(curl -fsS http://dalidou:8100/health | grep -o '"build_sha":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
# What the deployed branch tip should be
|
||||
EXPECTED_SHA=$(cd /srv/storage/atocore/app && git rev-parse HEAD)
|
||||
|
||||
# Compare
|
||||
if [ "$LIVE_SHA" = "$EXPECTED_SHA" ]; then
|
||||
echo "live is current at $LIVE_SHA"
|
||||
else
|
||||
echo "DRIFT: live $LIVE_SHA vs expected $EXPECTED_SHA"
|
||||
echo "run deploy.sh to sync"
|
||||
fi
|
||||
```
|
||||
|
||||
The `deploy.sh` script does exactly this comparison automatically
|
||||
in its post-deploy verification step (Step 6) and exits non-zero
|
||||
on mismatch. So the **simplest drift check** is just to run
|
||||
`deploy.sh` — if there's nothing to deploy, it succeeds quickly;
|
||||
if the live service is stale, it deploys and verifies.
|
||||
|
||||
If `/health` reports `build_sha: "unknown"`, the running container
|
||||
was started without `deploy.sh` (probably via `docker compose up`
|
||||
directly), and the build provenance was never recorded. Re-run
|
||||
via `deploy.sh` to fix.
|
||||
|
||||
The coarse `code_version` check is still useful as a quick visual
|
||||
sanity check — bumping `__version__` from `0.2.0` to `0.3.0`
|
||||
signals a meaningful release boundary even if the precise
|
||||
`build_sha` is what tools should compare against:
|
||||
|
||||
```bash
|
||||
# Quick sanity check (coarse)
|
||||
curl -s http://127.0.0.1:8100/health | grep -o '"code_version":"[^"]*"'
|
||||
grep '__version__' /srv/storage/atocore/app/src/atocore/__init__.py
|
||||
```
|
||||
|
||||
### Schema migrations on redeploy
|
||||
|
||||
When updating from an older `__version__`, the first startup after
|
||||
the redeploy runs the idempotent ALTER TABLE migrations in
|
||||
`_apply_migrations`. For a pre-0.2.0 → 0.2.0 upgrade the migrations
|
||||
add these columns to existing tables (all with safe defaults so no
|
||||
data is touched):
|
||||
|
||||
- `memories.project TEXT DEFAULT ''`
|
||||
- `memories.last_referenced_at DATETIME`
|
||||
- `memories.reference_count INTEGER DEFAULT 0`
|
||||
- `interactions.response TEXT DEFAULT ''`
|
||||
- `interactions.memories_used TEXT DEFAULT '[]'`
|
||||
- `interactions.chunks_used TEXT DEFAULT '[]'`
|
||||
- `interactions.client TEXT DEFAULT ''`
|
||||
- `interactions.session_id TEXT DEFAULT ''`
|
||||
- `interactions.project TEXT DEFAULT ''`
|
||||
|
||||
Plus new indexes on the new columns. No row data is modified. The
|
||||
migration is safe to run against a database that already has the
|
||||
columns — the `_column_exists` check makes each ALTER a no-op in
|
||||
that case.
|
||||
|
||||
Backup the database before any redeploy (via `POST /admin/backup`)
|
||||
if you want a pre-upgrade snapshot. The migration is additive and
|
||||
reversible by restoring the snapshot.
|
||||
|
||||
## Deferred
|
||||
|
||||
- backup automation
|
||||
|
||||
@@ -24,15 +24,46 @@ read-only additive mode.
|
||||
- Phase 5 - Project State
|
||||
- Phase 7 - Context Builder
|
||||
|
||||
### Partial
|
||||
### Baseline Complete
|
||||
|
||||
- Phase 4 - Identity / Preferences
|
||||
- Phase 8 - OpenClaw Integration
|
||||
- Phase 4 - Identity / Preferences. As of 2026-04-12: 3 identity
|
||||
memories (role, projects, infrastructure) and 3 preference memories
|
||||
(no API keys, multi-model collab, action-over-discussion) seeded
|
||||
on live Dalidou. Identity/preference band surfaces in context packs
|
||||
at 5% budget ratio. Future identity/preference extraction happens
|
||||
organically via the nightly LLM extraction pipeline.
|
||||
|
||||
- Phase 8 - OpenClaw Integration (baseline only, not primary surface).
|
||||
As of 2026-04-15 the T420 OpenClaw helper (`t420-openclaw/atocore.py`)
|
||||
is verified end-to-end against live Dalidou: health check, auto-context
|
||||
with project detection, Trusted Project State surfacing, project-memory
|
||||
band, fail-open on unreachable host. Tested from both the development
|
||||
machine and the T420 via SSH. Scope is narrow: **14 request shapes
|
||||
against ~44 server routes**, predominantly read-oriented plus
|
||||
`POST/DELETE /project/state` and `POST /ingest/sources`. Memory
|
||||
management, interactions capture (covered separately by the OpenClaw
|
||||
capture plugin), admin/backup, entities, triage, and extraction write
|
||||
paths remain out of this client's surface by design — they are scoped
|
||||
to the operator client (`scripts/atocore_client.py`) per the
|
||||
read-heavy additive integration model. "Primary integration" is
|
||||
therefore overclaim; "baseline read + project-state write helper" is
|
||||
the accurate framing.
|
||||
|
||||
### Baseline Complete
|
||||
|
||||
- Phase 9 - Reflection (all three foundation commits landed:
|
||||
A capture, B reinforcement, C candidate extraction + review queue)
|
||||
A capture, B reinforcement, C candidate extraction + review queue).
|
||||
As of 2026-04-11 the capture → reinforce half runs automatically on
|
||||
every Stop-hook capture (length-aware token-overlap matcher handles
|
||||
paragraph-length memories), and project-scoped memories now reach
|
||||
the context pack via a dedicated `--- Project Memories ---` band
|
||||
between identity/preference and retrieved chunks. The extract half
|
||||
is still a manual / batch flow by design (`scripts/atocore_client.py
|
||||
batch-extract` + `triage`). First live batch-extract run over 42
|
||||
captured interactions produced 1 candidate (rule extractor is
|
||||
conservative and keys on structural cues like `## Decision:`
|
||||
headings that rarely appear in conversational LLM responses) —
|
||||
extractor tuning is a known follow-up.
|
||||
|
||||
### Not Yet Complete In The Intended Sense
|
||||
|
||||
@@ -44,8 +75,9 @@ read-only additive mode.
|
||||
|
||||
### Engineering Layer Planning Sprint
|
||||
|
||||
The engineering layer is intentionally in planning, not implementation.
|
||||
The architecture docs below are the current state of that planning:
|
||||
**Status: complete.** All 8 architecture docs are drafted. The
|
||||
engineering layer is now ready for V1 implementation against the
|
||||
active project set.
|
||||
|
||||
- [engineering-query-catalog.md](architecture/engineering-query-catalog.md) —
|
||||
the 20 v1-required queries the engineering layer must answer
|
||||
@@ -55,71 +87,97 @@ The architecture docs below are the current state of that planning:
|
||||
Layer 0 → Layer 2 pipeline, triggers, review queue mechanics
|
||||
- [conflict-model.md](architecture/conflict-model.md) —
|
||||
detection, representation, and resolution of contradictory facts
|
||||
- [tool-handoff-boundaries.md](architecture/tool-handoff-boundaries.md) —
|
||||
KB-CAD / KB-FEM one-way mirror stance, ingest endpoints, drift handling
|
||||
- [representation-authority.md](architecture/representation-authority.md) —
|
||||
canonical home matrix across PKM / KB / repos / AtoCore for 22 fact kinds
|
||||
- [human-mirror-rules.md](architecture/human-mirror-rules.md) —
|
||||
templates, regeneration triggers, edit flow, "do not edit" enforcement
|
||||
- [engineering-v1-acceptance.md](architecture/engineering-v1-acceptance.md) —
|
||||
measurable done definition with 23 acceptance criteria
|
||||
- [engineering-knowledge-hybrid-architecture.md](architecture/engineering-knowledge-hybrid-architecture.md) —
|
||||
the 5-layer model (from the previous planning wave)
|
||||
- [engineering-ontology-v1.md](architecture/engineering-ontology-v1.md) —
|
||||
the initial V1 object and relationship inventory (previous wave)
|
||||
- [project-identity-canonicalization.md](architecture/project-identity-canonicalization.md) —
|
||||
the helper-at-every-service-boundary contract that keeps the
|
||||
trust hierarchy dependable across alias and canonical-id callers;
|
||||
required reading before adding new project-keyed entity surfaces
|
||||
in the V1 implementation sprint
|
||||
|
||||
Still to draft before engineering-layer implementation begins:
|
||||
The next concrete next step is the V1 implementation sprint, which
|
||||
should follow engineering-v1-acceptance.md as its checklist, and
|
||||
must apply the project-identity-canonicalization contract at every
|
||||
new service-layer entry point.
|
||||
|
||||
- tool-handoff-boundaries.md (KB-CAD / KB-FEM read vs write)
|
||||
- human-mirror-rules.md (templates, triggers, edit flow)
|
||||
- representation-authority.md (PKM / KB / repo / AtoCore canonical home matrix)
|
||||
- engineering-v1-acceptance.md (done definition)
|
||||
### LLM Client Integration
|
||||
|
||||
## What Is Real Today
|
||||
A separate but related architectural concern: how AtoCore is reachable
|
||||
from many different LLM client contexts (OpenClaw, Claude Code, future
|
||||
Codex skills, future MCP server). The layering rule is documented in:
|
||||
|
||||
- canonical AtoCore runtime on Dalidou
|
||||
- canonical machine DB and vector store on Dalidou
|
||||
- project registry with:
|
||||
- template
|
||||
- proposal preview
|
||||
- register
|
||||
- update
|
||||
- refresh
|
||||
- read-only additive OpenClaw helper on the T420
|
||||
- seeded project corpus for:
|
||||
- `p04-gigabit`
|
||||
- `p05-interferometer`
|
||||
- `p06-polisher`
|
||||
- conservative Trusted Project State for those active projects
|
||||
- first operational backup foundation for SQLite + project registry
|
||||
- implementation-facing architecture notes for future engineering knowledge work
|
||||
- first organic routing layer in OpenClaw via:
|
||||
- `detect-project`
|
||||
- `auto-context`
|
||||
- [llm-client-integration.md](architecture/llm-client-integration.md) —
|
||||
three-layer shape: HTTP API → shared operator client
|
||||
(`scripts/atocore_client.py`) → per-agent thin frontends; the
|
||||
shared client is the canonical backbone every new client should
|
||||
shell out to instead of reimplementing HTTP calls
|
||||
|
||||
This sits implicitly between Phase 8 (OpenClaw) and Phase 11
|
||||
(multi-model). Memory-review and engineering-entity commands are
|
||||
deferred from the shared client until their workflows are exercised.
|
||||
|
||||
## What Is Real Today (updated 2026-04-16)
|
||||
|
||||
- canonical AtoCore runtime on Dalidou (`775960c`, deploy.sh verified)
|
||||
- 33,253 vectors across 6 registered projects
|
||||
- 234 captured interactions (192 claude-code, 38 openclaw, 4 test)
|
||||
- 6 registered projects:
|
||||
- `p04-gigabit` (483 docs, 15 state entries)
|
||||
- `p05-interferometer` (109 docs, 18 state entries)
|
||||
- `p06-polisher` (564 docs, 19 state entries)
|
||||
- `atomizer-v2` (568 docs, 5 state entries)
|
||||
- `abb-space` (6 state entries)
|
||||
- `atocore` (drive source, 47 state entries)
|
||||
- 110 Trusted Project State entries across all projects (decisions, requirements, facts, contacts, milestones)
|
||||
- 84 active memories (31 project, 23 knowledge, 10 episodic, 8 adaptation, 7 preference, 5 identity)
|
||||
- context pack assembly with 4 tiers: Trusted Project State > identity/preference > project memories > retrieved chunks
|
||||
- query-relevance memory ranking with overlap-density scoring
|
||||
- retrieval eval harness: 18 fixtures, 17/18 passing on live
|
||||
- 303 tests passing
|
||||
- nightly pipeline: backup → cleanup → rsync → OpenClaw import → vault refresh → extract → triage → **auto-promote/expire** → weekly synth/lint → **retrieval harness** → **pipeline summary to project state**
|
||||
- Phase 10 operational: reinforcement-based auto-promotion (ref_count ≥ 3, confidence ≥ 0.7) + stale candidate expiry (14 days unreinforced)
|
||||
- pipeline health visible in dashboard: interaction totals by client, pipeline last_run, harness results, triage stats
|
||||
- off-host backup to clawdbot (T420) via rsync
|
||||
- both Claude Code and OpenClaw capture interactions to AtoCore (OpenClaw via `before_agent_start` + `llm_output` plugin, verified live)
|
||||
- DEV-LEDGER.md as shared operating memory between Claude and Codex
|
||||
- observability dashboard at GET /admin/dashboard
|
||||
|
||||
## Now
|
||||
|
||||
These are the current practical priorities.
|
||||
|
||||
1. Finish practical OpenClaw integration
|
||||
- make the helper lifecycle feel natural in daily use
|
||||
- use the new organic routing layer for project-knowledge questions
|
||||
- confirm fail-open behavior remains acceptable
|
||||
- keep AtoCore clearly additive
|
||||
2. Tighten retrieval quality
|
||||
- reduce cross-project competition
|
||||
- improve ranking on short or ambiguous prompts
|
||||
- add only a few anchor docs where retrieval is still weak
|
||||
3. Continue controlled ingestion
|
||||
- deepen active projects selectively
|
||||
- avoid noisy bulk corpus growth
|
||||
4. Strengthen operational boringness
|
||||
- backup and restore procedure
|
||||
- Chroma rebuild / backup policy
|
||||
- retention and restore validation
|
||||
1. **Observe the enhanced pipeline** — let the nightly pipeline run for a
|
||||
week with the new harness + summary + auto-promote steps. Check the
|
||||
dashboard daily. Verify pipeline summary populates correctly.
|
||||
2. **Knowledge density** — run batch extraction over the full 234
|
||||
interactions (`--since 2026-01-01`) to mine the backlog for knowledge.
|
||||
Target: 100+ active memories.
|
||||
3. **Multi-model triage** (Phase 11 entry) — switch auto-triage to a
|
||||
different model than the extractor for independent validation
|
||||
4. **Fix p04-constraints harness failure** — retrieval doesn't surface
|
||||
"Zerodur" for p04 constraint queries. Investigate if it's a missing
|
||||
memory or retrieval ranking issue.
|
||||
|
||||
## Next
|
||||
|
||||
These are the next major layers after the current practical pass.
|
||||
These are the next major layers after the current stabilization pass.
|
||||
|
||||
1. Clarify AtoDrive as a real operational truth layer
|
||||
2. Mature identity / preferences handling
|
||||
3. Improve observability for:
|
||||
- retrieval quality
|
||||
- context-pack inspection
|
||||
- comparison of behavior with and without AtoCore
|
||||
1. Phase 6 AtoDrive — clarify Google Drive as a trusted operational
|
||||
source and ingest from it
|
||||
2. Phase 13 Hardening — Chroma backup policy, monitoring, alerting,
|
||||
failure visibility beyond log files
|
||||
3. Engineering V1 implementation sprint — once knowledge density is
|
||||
sufficient and the pipeline feels boring and dependable
|
||||
|
||||
## Later
|
||||
|
||||
@@ -137,9 +195,17 @@ direction, but not yet ready for immediate implementation.
|
||||
|
||||
These remain intentionally deferred.
|
||||
|
||||
- automatic write-back from OpenClaw into AtoCore
|
||||
- automatic memory promotion
|
||||
- reflection loop integration
|
||||
- ~~automatic write-back from OpenClaw into AtoCore~~ — OpenClaw capture
|
||||
plugin now exists (`openclaw-plugins/atocore-capture/`), interactions
|
||||
flow. Write-back of promoted memories back to OpenClaw's own memory
|
||||
system is still deferred.
|
||||
- ~~automatic memory promotion~~ — Phase 10 complete: auto-triage handles
|
||||
extraction candidates, reinforcement-based auto-promotion graduates
|
||||
candidates referenced 3+ times to active, stale candidates expire
|
||||
after 14 days unreinforced.
|
||||
- ~~reflection loop integration~~ — fully operational: capture (both
|
||||
clients) → reinforce (automatic) → extract (nightly cron, sonnet) →
|
||||
auto-triage (nightly, sonnet) → only needs_human reaches the user.
|
||||
- replacing OpenClaw's own memory system
|
||||
- live machine-DB sync between machines
|
||||
- full ontology / graph expansion before the current baseline is stable
|
||||
|
||||
@@ -20,45 +20,65 @@ This working list should be read alongside:
|
||||
|
||||
## Immediate Next Steps
|
||||
|
||||
1. Use the T420 `atocore-context` skill and the new organic routing layer in
|
||||
1. ~~Re-run the backup/restore drill~~ — DONE 2026-04-11, full pass
|
||||
2. ~~Turn on auto-capture of Claude Code sessions~~ — DONE 2026-04-11,
|
||||
Stop hook via `deploy/hooks/capture_stop.py` → `POST /interactions`
|
||||
with `reinforce=false`; kill switch: `ATOCORE_CAPTURE_DISABLED=1`
|
||||
2a. Run a short real-use pilot with auto-capture on
|
||||
- verify interactions are landing in Dalidou
|
||||
- check prompt/response quality and truncation
|
||||
- confirm fail-open: no user-visible impact when Dalidou is down
|
||||
3. Use the T420 `atocore-context` skill and the new organic routing layer in
|
||||
real OpenClaw workflows
|
||||
- confirm `auto-context` feels natural
|
||||
- confirm project inference is good enough in practice
|
||||
- confirm the fail-open behavior remains acceptable in practice
|
||||
2. Review retrieval quality after the first real project ingestion batch
|
||||
4. Review retrieval quality after the first real project ingestion batch
|
||||
- check whether the top hits are useful
|
||||
- check whether trusted project state remains dominant
|
||||
- reduce cross-project competition and prompt ambiguity where needed
|
||||
- use `debug-context` to inspect the exact last AtoCore supplement
|
||||
3. Treat the active-project full markdown/text wave as complete
|
||||
5. Treat the active-project full markdown/text wave as complete
|
||||
- `p04-gigabit`
|
||||
- `p05-interferometer`
|
||||
- `p06-polisher`
|
||||
4. Define a cleaner source refresh model
|
||||
6. Define a cleaner source refresh model
|
||||
- make the difference between source truth, staged inputs, and machine store
|
||||
explicit
|
||||
- move toward a project source registry and refresh workflow
|
||||
- foundation now exists via project registry + per-project refresh API
|
||||
- registration policy + template + proposal + approved registration are now
|
||||
the normal path for new projects
|
||||
5. Move to Wave 2 trusted-operational ingestion
|
||||
7. Move to Wave 2 trusted-operational ingestion
|
||||
- curated dashboards
|
||||
- decision logs
|
||||
- milestone/current-status views
|
||||
- operational truth, not just raw project notes
|
||||
6. Integrate the new engineering architecture docs into active planning, not immediate schema code
|
||||
8. Integrate the new engineering architecture docs into active planning, not immediate schema code
|
||||
- keep `docs/architecture/engineering-knowledge-hybrid-architecture.md` as the target layer model
|
||||
- keep `docs/architecture/engineering-ontology-v1.md` as the V1 structured-domain target
|
||||
- do not start entity/relationship persistence until the ingestion, retrieval, registry, and backup baseline feels boring and stable
|
||||
7. Define backup and export procedures for Dalidou
|
||||
- exercise the new SQLite + registry snapshot path on Dalidou
|
||||
- Chroma backup or rebuild policy
|
||||
- retention and restore validation
|
||||
- admin backup endpoint now supports `include_chroma` cold snapshot
|
||||
under the ingestion lock and `validate` confirms each snapshot is
|
||||
openable; remaining work is the operational retention policy
|
||||
8. Keep deeper automatic runtime integration modest until the organic read-only
|
||||
model has proven value
|
||||
9. Finish the boring operations baseline around backup
|
||||
- retention policy cleanup script (snapshots dir grows
|
||||
monotonically today)
|
||||
- off-Dalidou backup target (at minimum an rsync to laptop or
|
||||
another host so a single-disk failure isn't terminal)
|
||||
- automatic post-backup validation (have `create_runtime_backup`
|
||||
call `validate_backup` on its own output and refuse to
|
||||
declare success if validation fails)
|
||||
- DONE in commits be40994 / 0382238 / 3362080 / this one:
|
||||
- `create_runtime_backup` + `list_runtime_backups` +
|
||||
`validate_backup` + `restore_runtime_backup` with CLI
|
||||
- `POST /admin/backup` with `include_chroma=true` under
|
||||
the ingestion lock
|
||||
- `/health` build_sha / build_time / build_branch provenance
|
||||
- `deploy.sh` self-update re-exec guard + build_sha drift
|
||||
verification
|
||||
- live drill procedure in `docs/backup-restore-procedure.md`
|
||||
with failure-mode table and the memory_type=episodic
|
||||
marker pattern from the 2026-04-09 drill
|
||||
10. Keep deeper automatic runtime integration modest until the organic read-only
|
||||
model has proven value
|
||||
|
||||
## Trusted State Status
|
||||
|
||||
@@ -117,7 +137,12 @@ P06:
|
||||
|
||||
- automatic write-back from OpenClaw into AtoCore
|
||||
- automatic memory promotion
|
||||
- reflection loop integration
|
||||
- ~~reflection loop integration~~ — baseline now landed (2026-04-11):
|
||||
Stop hook runs reinforce automatically, project memories are folded
|
||||
into the context pack, batch-extract and triage CLIs exist. What
|
||||
remains deferred: scheduled/automatic batch extraction and extractor
|
||||
rule tuning (rule-based extractor produced 1 candidate from 42 real
|
||||
captures — needs new cues for conversational LLM content).
|
||||
- replacing OpenClaw's own memory system
|
||||
- syncing the live machine DB between machines
|
||||
|
||||
@@ -139,6 +164,116 @@ The next batch is successful if:
|
||||
- project ingestion remains controlled rather than noisy
|
||||
- the canonical Dalidou instance stays stable
|
||||
|
||||
## Retrieval Quality Review — 2026-04-11
|
||||
|
||||
First sweep with real project-hinted queries on Dalidou. Used
|
||||
`POST /context/build` against p04, p05, p06 with representative
|
||||
questions and inspected `formatted_context`.
|
||||
|
||||
Findings:
|
||||
|
||||
- **Trusted Project State is surfacing correctly.** The DECISION and
|
||||
REQUIREMENT categories appear at the top of the pack and include
|
||||
the expected key facts (e.g. p04 "Option B conical-back mirror
|
||||
architecture"). This is the strongest signal in the pack today.
|
||||
- **Chunk retrieval is relevant on-topic but broad.** Top chunks for
|
||||
the p04 architecture query are PDR intro, CAD assembly overview,
|
||||
and the index — all on the right project but none of them directly
|
||||
answer the "why was Option B chosen" question. The authoritative
|
||||
answer sits in Project State, not in the chunks.
|
||||
- **Active memories are NOT reaching the pack.** The context builder
|
||||
surfaces Trusted Project State and retrieved chunks but does not
|
||||
include the 21 active project/knowledge memories. Reinforcement
|
||||
(Phase 9 Commit B) bumps memory confidence without the memory ever
|
||||
being read back into a prompt — the reflection loop has no outlet
|
||||
on the retrieval side. This is a design gap, not a bug: needs a
|
||||
decision on whether memories should feed into context assembly,
|
||||
and if so at what trust level (below project_state, above chunks).
|
||||
- **Cross-project bleed is low.** The p04 query did pull one p05
|
||||
chunk (CGH_Design_Input_for_AOM) as the bottom hit but the top-4
|
||||
were all p04.
|
||||
|
||||
Proposed follow-ups (not yet scheduled):
|
||||
|
||||
1. ~~Decide whether memories should be folded into `formatted_context`
|
||||
and under what section header.~~ DONE 2026-04-11 (commits 8ea53f4,
|
||||
5913da5, 1161645). A `--- Project Memories ---` band now sits
|
||||
between identity/preference and retrieved chunks, gated on a
|
||||
canonical project hint to prevent cross-project bleed. Budget
|
||||
ratio 0.25 (tuned empirically — paragraph memories are ~400 chars
|
||||
and earlier 0.15 ratio starved the first entry by one char).
|
||||
Verified live: p04 architecture query surfaces the Option B memory.
|
||||
2. Re-run the same three queries after any builder change and compare
|
||||
`formatted_context` diffs — still open, and is the natural entry
|
||||
point for the retrieval eval harness on the roadmap.
|
||||
|
||||
## Reflection Loop Live Check — 2026-04-11
|
||||
|
||||
First real run of `batch-extract` across 42 captured Claude Code
|
||||
interactions on Dalidou produced exactly **1 candidate**, and that
|
||||
candidate was a synthetic test capture from earlier in the session
|
||||
(rejected). Finding:
|
||||
|
||||
- The rule-based extractor in `src/atocore/memory/extractor.py` keys
|
||||
on explicit structural cues (decision headings like
|
||||
`## Decision: ...`, preference sentences, etc.). Real Claude Code
|
||||
responses are conversational and almost never contain those cues.
|
||||
- This means the capture → extract half of the reflection loop is
|
||||
effectively inert against organic LLM sessions until either the
|
||||
rules are broadened (new cue families: "we chose X because...",
|
||||
"the selected approach is...", etc.) or an LLM-assisted extraction
|
||||
path is added alongside the rule-based one.
|
||||
- Capture → reinforce is working correctly on live data (length-aware
|
||||
matcher verified on live paraphrase of a p04 memory).
|
||||
|
||||
Follow-up candidates:
|
||||
|
||||
1. ~~Extractor rule expansion~~ — Day 2 baseline showed 0% recall
|
||||
across 5 distinct miss classes; rule expansion cannot close a
|
||||
5-way miss. Deprioritized.
|
||||
2. ~~LLM-assisted extractor~~ — DONE 2026-04-12. `extractor_llm.py`
|
||||
shells out to `claude -p` (Haiku, OAuth, no API key). First live
|
||||
run: 100% recall, 2.55 yield/interaction on a 20-interaction
|
||||
labeled set. First triage: 51 candidates → 16 promoted, 35
|
||||
rejected (31% accept rate). Active memories 20 → 36.
|
||||
3. ~~Retrieval eval harness~~ — DONE 2026-04-11 (scripts/retrieval_eval.py,
|
||||
6/6 passing). Expansion to 15-20 fixtures is mini-phase Day 6.
|
||||
|
||||
## Extractor Scope — 2026-04-12
|
||||
|
||||
What the LLM-assisted extractor (`src/atocore/memory/extractor_llm.py`)
|
||||
extracts from conversational Claude Code captures:
|
||||
|
||||
**In scope:**
|
||||
|
||||
- Architectural commitments (e.g. "Z-axis is engage/retract, not
|
||||
continuous position")
|
||||
- Ratified decisions with project scope (e.g. "USB SSD mandatory on
|
||||
RPi for telemetry storage")
|
||||
- Durable engineering facts (e.g. "telemetry data rate ~29 MB/hour")
|
||||
- Working rules and adaptation patterns (e.g. "extraction stays off
|
||||
the capture hot path")
|
||||
- Interface invariants (e.g. "controller-job.v1 in, run-log.v1 out;
|
||||
no firmware change needed")
|
||||
|
||||
**Out of scope (intentionally rejected by triage):**
|
||||
|
||||
- Transient roadmap / plan steps that will be stale in a week
|
||||
- Operational instructions ("run this command to deploy")
|
||||
- Process rules that live in DEV-LEDGER.md / AGENTS.md, not in memory
|
||||
- Implementation details that are too granular (individual field names
|
||||
when the parent concept is already captured)
|
||||
- Already-fixed review findings (P1/P2 that no longer apply)
|
||||
- Duplicates of existing active memories with wrong project tags
|
||||
|
||||
**Trust model:**
|
||||
|
||||
- Extraction stays off the capture hot path (batch / manual only)
|
||||
- All candidates land as `status=candidate`, never auto-promoted
|
||||
- Human or auto-triage reviews before promotion to active
|
||||
- Future direction: multi-model extraction + triage (Codex/Gemini as
|
||||
second-pass reviewers for robustness against single-model bias)
|
||||
|
||||
## Long-Run Goal
|
||||
|
||||
The long-run target is:
|
||||
|
||||
56
docs/openclaw-atocore-integration-proposal.md
Normal file
56
docs/openclaw-atocore-integration-proposal.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# OpenClaw -> AtoCore Integration Proposal
|
||||
|
||||
One-way pull is the right pattern.
|
||||
|
||||
**Stable surface to pull**
|
||||
- Durable files in the OpenClaw workspace:
|
||||
- `SOUL.md`
|
||||
- `USER.md`
|
||||
- `MODEL-ROUTING.md`
|
||||
- `MEMORY.md`
|
||||
- `memory/YYYY-MM-DD.md`
|
||||
- `memory/heartbeat-state.json`
|
||||
- `HEARTBEAT.md` only as operational state, not long-term truth
|
||||
- These are explicitly documented in `t420-openclaw/AGENTS.md` as the continuity layer OpenClaw reads every session.
|
||||
|
||||
**Volatile vs durable**
|
||||
- Durable:
|
||||
- `SOUL.md`, `USER.md`, `MODEL-ROUTING.md`, `MEMORY.md`
|
||||
- dated memory notes under `memory/`
|
||||
- explicit JSON state like `memory/heartbeat-state.json`
|
||||
- Volatile:
|
||||
- in-session context
|
||||
- ephemeral heartbeat work
|
||||
- transient orchestration state
|
||||
- platform response buffers
|
||||
- Semi-durable:
|
||||
- `HEARTBEAT.md` and operational notes; useful for importer hints, but not canonical identity/memory truth
|
||||
|
||||
**Formats**
|
||||
- Mostly Markdown
|
||||
- Some JSON (`heartbeat-state.json`)
|
||||
- No stable OpenClaw-local DB or API surface is visible in this snapshot
|
||||
|
||||
**How pull should work**
|
||||
- Start with cron-based filesystem reads, not an OpenClaw HTTP API.
|
||||
- Read the durable files on a schedule, hash them, and import only deltas.
|
||||
- Map them by type:
|
||||
- `SOUL.md` / `USER.md` -> identity/preferences review candidates
|
||||
- `MEMORY.md` -> curated long-term memory candidates
|
||||
- `memory/YYYY-MM-DD.md` -> interaction/episodic import stream
|
||||
- `heartbeat-state.json` -> low-priority ops metadata only if useful
|
||||
|
||||
**Discord**
|
||||
- I do not see a documented durable Discord message store in the OpenClaw workspace snapshot.
|
||||
- `AGENTS.md` references Discord behavior, but not a canonical local log/database.
|
||||
- Treat Discord as transient unless OpenClaw exposes an explicit export/log file later.
|
||||
|
||||
**Biggest risk**
|
||||
- Importing raw OpenClaw files as truth will blur curated memory and noisy session chatter.
|
||||
- Mitigation: importer should classify by source tier, preserve provenance, and default to candidate/episodic ingestion rather than active memory promotion.
|
||||
|
||||
**Recommendation**
|
||||
- Do not build two-way sync.
|
||||
- Do not require OpenClaw to change architecture.
|
||||
- Build one importer against the file continuity layer first.
|
||||
- Add a formal export surface later only if the importer becomes too heuristic.
|
||||
96
docs/operations.md
Normal file
96
docs/operations.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# AtoCore Operations
|
||||
|
||||
Current operating order for improving AtoCore:
|
||||
|
||||
1. Retrieval-quality pass
|
||||
2. Wave 2 trusted-operational ingestion
|
||||
3. AtoDrive clarification
|
||||
4. Restore and ops validation
|
||||
|
||||
## Retrieval-Quality Pass
|
||||
|
||||
Current live behavior:
|
||||
|
||||
- broad prompts like `gigabit` and `polisher` can surface archive/history noise
|
||||
- meaningful project prompts perform much better
|
||||
- ranking quality now matters more than raw corpus growth
|
||||
|
||||
Use the operator client to audit retrieval:
|
||||
|
||||
```bash
|
||||
python scripts/atocore_client.py audit-query "gigabit" 5
|
||||
python scripts/atocore_client.py audit-query "polisher" 5
|
||||
python scripts/atocore_client.py audit-query "mirror frame stiffness requirements and selected architecture" 5 p04-gigabit
|
||||
python scripts/atocore_client.py audit-query "interferometer error budget and vendor selection constraints" 5 p05-interferometer
|
||||
python scripts/atocore_client.py audit-query "polisher system map shared contracts and calibration workflow" 5 p06-polisher
|
||||
```
|
||||
|
||||
What to improve:
|
||||
|
||||
- reduce `_archive`, `pre-cleanup`, `pre-migration`, and `History` prominence
|
||||
- prefer current-status, decision, requirement, architecture-freeze, and milestone docs
|
||||
- prefer trusted project-state when it expresses current truth
|
||||
- avoid letting broad single-word prompts drift into stale chunks
|
||||
|
||||
## Wave 2 Trusted-Operational Ingestion
|
||||
|
||||
Do not ingest the whole PKM vault next.
|
||||
|
||||
Prioritize, for each active project:
|
||||
|
||||
- current status
|
||||
- current decisions
|
||||
- requirements baseline
|
||||
- architecture freeze / current baseline
|
||||
- milestone plan
|
||||
- next actions
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
python scripts/atocore_client.py project-state p04-gigabit
|
||||
python scripts/atocore_client.py project-state p05-interferometer
|
||||
python scripts/atocore_client.py project-state p06-polisher
|
||||
python scripts/atocore_client.py refresh-project p04-gigabit
|
||||
python scripts/atocore_client.py refresh-project p05-interferometer
|
||||
python scripts/atocore_client.py refresh-project p06-polisher
|
||||
```
|
||||
|
||||
## AtoDrive Clarification
|
||||
|
||||
Treat AtoDrive as a curated trusted-operational source, not a generic dump.
|
||||
|
||||
Good candidates:
|
||||
|
||||
- current dashboards
|
||||
- approved baselines
|
||||
- architecture freezes
|
||||
- decision logs
|
||||
- milestone and next-step views
|
||||
|
||||
Avoid by default:
|
||||
|
||||
- duplicated exports
|
||||
- stale snapshots
|
||||
- generic archives
|
||||
- exploratory notes that are not designated current truth
|
||||
|
||||
## Restore and Ops Validation
|
||||
|
||||
Backups are not enough until restore has been tested.
|
||||
|
||||
Validate:
|
||||
|
||||
- SQLite metadata restore
|
||||
- Chroma restore or rebuild
|
||||
- project registry restore
|
||||
- project refresh after recovery
|
||||
- retrieval audit before and after recovery
|
||||
|
||||
Baseline capture:
|
||||
|
||||
```bash
|
||||
python scripts/atocore_client.py health
|
||||
python scripts/atocore_client.py stats
|
||||
python scripts/atocore_client.py projects
|
||||
```
|
||||
274
docs/universal-consumption.md
Normal file
274
docs/universal-consumption.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Universal Consumption — Connecting LLM Clients to AtoCore
|
||||
|
||||
Phase 1 of the Master Brain plan. Every LLM interaction across the ecosystem
|
||||
pulls context from AtoCore automatically, without the user or agent having
|
||||
to remember to ask for it.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ AtoCore HTTP API │ ← single source of truth
|
||||
│ http://dalidou:8100│
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
┌───┴────┐ ┌─────┴────┐ ┌────┴────┐
|
||||
│ MCP │ │ OpenClaw │ │ HTTP │
|
||||
│ server │ │ plugin │ │ proxy │
|
||||
└───┬────┘ └──────┬───┘ └────┬────┘
|
||||
│ │ │
|
||||
Claude/Cursor/ OpenClaw Codex/Ollama/
|
||||
Zed/Windsurf any OpenAI-compat client
|
||||
```
|
||||
|
||||
Three adapters, one HTTP backend. Each adapter is a thin passthrough — no
|
||||
business logic duplicated.
|
||||
|
||||
---
|
||||
|
||||
## Adapter 1: MCP Server (Claude Desktop, Claude Code, Cursor, Zed, Windsurf)
|
||||
|
||||
The MCP server is `scripts/atocore_mcp.py` — stdlib-only Python, stdio
|
||||
transport, wraps the HTTP API. Claude-family clients see AtoCore as built-in
|
||||
tools just like `Read` or `Bash`.
|
||||
|
||||
### Tools exposed
|
||||
|
||||
- **`atocore_context`** (most important): Full context pack for a query —
|
||||
Trusted Project State + memories + retrieved chunks. Use at the start of
|
||||
any project-related conversation to ground it.
|
||||
- **`atocore_search`**: Semantic search over ingested documents (top-K chunks).
|
||||
- **`atocore_memory_list`**: List active memories, filterable by project + type.
|
||||
- **`atocore_memory_create`**: Propose a candidate memory (enters triage queue).
|
||||
- **`atocore_project_state`**: Get Trusted Project State entries by category.
|
||||
- **`atocore_projects`**: List registered projects + aliases.
|
||||
- **`atocore_health`**: Service status check.
|
||||
|
||||
### Registration
|
||||
|
||||
#### Claude Code (CLI)
|
||||
```bash
|
||||
claude mcp add atocore -- python C:/Users/antoi/ATOCore/scripts/atocore_mcp.py
|
||||
claude mcp list # verify: "atocore ... ✓ Connected"
|
||||
```
|
||||
|
||||
#### Claude Desktop (GUI)
|
||||
Edit `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
(macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"atocore": {
|
||||
"command": "python",
|
||||
"args": ["C:/Users/antoi/ATOCore/scripts/atocore_mcp.py"],
|
||||
"env": {
|
||||
"ATOCORE_URL": "http://dalidou:8100"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Restart Claude Desktop.
|
||||
|
||||
#### Cursor / Zed / Windsurf
|
||||
Similar JSON config in each tool's MCP settings. Consult their docs —
|
||||
the config schema is standard MCP.
|
||||
|
||||
### Configuration
|
||||
|
||||
Environment variables the MCP server honors:
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `ATOCORE_URL` | `http://dalidou:8100` | Where to reach AtoCore |
|
||||
| `ATOCORE_TIMEOUT` | `10` | Per-request HTTP timeout (seconds) |
|
||||
|
||||
### Behavior
|
||||
|
||||
- Fail-open: if Dalidou is unreachable, tools return "AtoCore unavailable"
|
||||
error messages but don't crash the client.
|
||||
- Zero business logic: every tool is a direct HTTP passthrough.
|
||||
- stdlib only: no MCP SDK dependency.
|
||||
|
||||
---
|
||||
|
||||
## Adapter 2: OpenClaw Plugin (`openclaw-plugins/atocore-capture/handler.js`)
|
||||
|
||||
The plugin on T420 OpenClaw has two responsibilities:
|
||||
|
||||
1. **CAPTURE**: On `before_agent_start` + `llm_output`, POST completed turns
|
||||
to AtoCore `/interactions` (existing).
|
||||
2. **PULL**: On `before_prompt_build`, call `/context/build` and inject the
|
||||
context pack via `prependContext` so the agent's system prompt includes
|
||||
AtoCore knowledge.
|
||||
|
||||
### Deployment
|
||||
|
||||
The plugin is loaded from
|
||||
`/tmp/atocore-openclaw-capture-plugin/openclaw-plugins/atocore-capture/`
|
||||
on the T420 (per OpenClaw's plugin config at `~/.openclaw/openclaw.json`).
|
||||
|
||||
To update:
|
||||
```bash
|
||||
scp openclaw-plugins/atocore-capture/handler.js \
|
||||
papa@192.168.86.39:/tmp/atocore-openclaw-capture-plugin/openclaw-plugins/atocore-capture/index.js
|
||||
ssh papa@192.168.86.39 'systemctl --user restart openclaw-gateway'
|
||||
```
|
||||
|
||||
Verify in gateway logs: look for "ready (7 plugins: acpx, atocore-capture, ...)"
|
||||
|
||||
### Configuration (env vars set on T420)
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `ATOCORE_BASE_URL` | `http://dalidou:8100` | AtoCore HTTP endpoint |
|
||||
| `ATOCORE_PULL_DISABLED` | (unset) | Set to `1` to disable context pull |
|
||||
|
||||
### Behavior
|
||||
|
||||
- Fail-open: AtoCore unreachable = no injection, no capture, agent runs
|
||||
normally.
|
||||
- 6s timeout on context pull, 10s on capture — won't stall the agent.
|
||||
- Context pack prepended as a clearly-bracketed block so the agent can see
|
||||
it's auto-injected grounding info.
|
||||
|
||||
---
|
||||
|
||||
## Adapter 3: HTTP Proxy (`scripts/atocore_proxy.py`)
|
||||
|
||||
A stdlib-only OpenAI-compatible HTTP proxy. Sits between any
|
||||
OpenAI-API-speaking client and the real provider, enriches every
|
||||
`/chat/completions` request with AtoCore context.
|
||||
|
||||
Works with:
|
||||
- **Codex CLI** (OpenAI-compatible endpoint)
|
||||
- **Ollama** (has OpenAI-compatible `/v1` endpoint since 0.1.24)
|
||||
- **LiteLLM**, **llama.cpp server**, custom agents
|
||||
- Anything that can be pointed at a custom base URL
|
||||
|
||||
### Start it
|
||||
|
||||
```bash
|
||||
# For Ollama (local models):
|
||||
ATOCORE_UPSTREAM=http://localhost:11434/v1 \
|
||||
python scripts/atocore_proxy.py
|
||||
|
||||
# For OpenAI cloud:
|
||||
ATOCORE_UPSTREAM=https://api.openai.com/v1 \
|
||||
ATOCORE_CLIENT_LABEL=codex \
|
||||
python scripts/atocore_proxy.py
|
||||
|
||||
# Test:
|
||||
curl http://127.0.0.1:11435/healthz
|
||||
```
|
||||
|
||||
### Point a client at it
|
||||
|
||||
Set the client's OpenAI base URL to `http://127.0.0.1:11435/v1`.
|
||||
|
||||
#### Ollama example:
|
||||
```bash
|
||||
OPENAI_BASE_URL=http://127.0.0.1:11435/v1 \
|
||||
some-openai-client --model llama3:8b
|
||||
```
|
||||
|
||||
#### Codex CLI:
|
||||
Set `OPENAI_BASE_URL=http://127.0.0.1:11435/v1` in your codex config.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `ATOCORE_URL` | `http://dalidou:8100` | AtoCore HTTP endpoint |
|
||||
| `ATOCORE_UPSTREAM` | (required) | Real provider base URL |
|
||||
| `ATOCORE_PROXY_PORT` | `11435` | Proxy listen port |
|
||||
| `ATOCORE_PROXY_HOST` | `127.0.0.1` | Proxy bind address |
|
||||
| `ATOCORE_CLIENT_LABEL` | `proxy` | Client id in captures |
|
||||
| `ATOCORE_INJECT` | `1` | Inject context (set `0` to disable) |
|
||||
| `ATOCORE_CAPTURE` | `1` | Capture interactions (set `0` to disable) |
|
||||
|
||||
### Behavior
|
||||
|
||||
- GET requests (model listing etc) pass through unchanged
|
||||
- POST to `/chat/completions` (or `/v1/chat/completions`) gets enriched:
|
||||
1. Last user message extracted as query
|
||||
2. AtoCore `/context/build` called with 6s timeout
|
||||
3. Pack injected as system message (or prepended to existing system)
|
||||
4. Enriched body forwarded to upstream
|
||||
5. After success, interaction POSTed to `/interactions` in background
|
||||
- Fail-open: AtoCore unreachable = pass through without injection
|
||||
- Streaming responses: currently buffered (not true stream). Good enough for
|
||||
most cases; can be upgraded later if needed.
|
||||
|
||||
### Running as a service
|
||||
|
||||
On Linux, create `~/.config/systemd/user/atocore-proxy.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=AtoCore HTTP proxy
|
||||
|
||||
[Service]
|
||||
Environment=ATOCORE_UPSTREAM=http://localhost:11434/v1
|
||||
Environment=ATOCORE_CLIENT_LABEL=ollama
|
||||
ExecStart=/usr/bin/python3 /path/to/scripts/atocore_proxy.py
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
Then: `systemctl --user enable --now atocore-proxy`
|
||||
|
||||
On Windows, register via Task Scheduler (similar pattern to backup task)
|
||||
or use NSSM to install as a service.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Fresh end-to-end test to confirm Phase 1 is working:
|
||||
|
||||
### For Claude Code (MCP)
|
||||
1. Open a new Claude Code session (not this one).
|
||||
2. Ask: "what do we know about p06 polisher's control architecture?"
|
||||
3. Claude should invoke `atocore_context` or `atocore_project_state`
|
||||
on its own and answer grounded in AtoCore data.
|
||||
|
||||
### For OpenClaw (plugin pull)
|
||||
1. Send a Discord message to OpenClaw: "what's the status on p04?"
|
||||
2. Check T420 logs: `journalctl --user -u openclaw-gateway --since "1 min ago" | grep atocore-pull`
|
||||
3. Expect: `atocore-pull:injected project=p04-gigabit chars=NNN`
|
||||
|
||||
### For proxy (any OpenAI-compat client)
|
||||
1. Start proxy with appropriate upstream
|
||||
2. Run a client query through it
|
||||
3. Check stderr: `[atocore-proxy] inject: project=... chars=...`
|
||||
4. Check `curl http://127.0.0.1:8100/interactions?client=proxy` — should
|
||||
show the captured turn
|
||||
|
||||
---
|
||||
|
||||
## Why not just MCP everywhere?
|
||||
|
||||
MCP is great for Claude-family clients but:
|
||||
- Not supported natively by Codex CLI, Ollama, or OpenAI's own API
|
||||
- No universal "attach MCP" mechanism in all LLM runtimes
|
||||
- HTTP APIs are truly universal
|
||||
|
||||
HTTP API is the truth, each adapter is the thinnest possible shim for its
|
||||
ecosystem. When new adapters are needed (Gemini CLI, Claude Code plugin
|
||||
system, etc.), they follow the same pattern.
|
||||
|
||||
---
|
||||
|
||||
## Future enhancements
|
||||
|
||||
- **Streaming passthrough** in the proxy (currently buffered for simplicity)
|
||||
- **Response grounding check**: parse assistant output for references to
|
||||
injected context, count reinforcement events
|
||||
- **Per-client metrics** in the dashboard: how often each client pulls,
|
||||
context pack size, injection rate
|
||||
- **Smart project detection**: today we use keyword matching; could use
|
||||
AtoCore's own project resolver endpoint
|
||||
140
docs/windows-backup-setup.md
Normal file
140
docs/windows-backup-setup.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Windows Main-Computer Backup Setup
|
||||
|
||||
The AtoCore backup pipeline runs nightly on Dalidou and already pushes snapshots
|
||||
off-host to the T420 (`papa@192.168.86.39`). This doc sets up a **second**,
|
||||
pull-based daily backup to your Windows main computer at
|
||||
`C:\Users\antoi\Documents\ATOCore_Backups\`.
|
||||
|
||||
Pull-based means the Windows machine pulls from Dalidou. This is simpler than
|
||||
push because Dalidou doesn't need SSH keys to reach Windows, and the backup
|
||||
only runs when the Windows machine is powered on and can reach Dalidou.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Windows 10/11 with OpenSSH client (built-in since Win10 1809)
|
||||
- SSH key-based auth to `papa@dalidou` already working (you're using it today)
|
||||
- `C:\Users\antoi\ATOCore\scripts\windows\atocore-backup-pull.ps1` present
|
||||
|
||||
## Test the script manually
|
||||
|
||||
```powershell
|
||||
powershell.exe -ExecutionPolicy Bypass -File `
|
||||
C:\Users\antoi\ATOCore\scripts\windows\atocore-backup-pull.ps1
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
[timestamp] === AtoCore backup pull starting ===
|
||||
[timestamp] Dalidou reachable.
|
||||
[timestamp] Pulling snapshots via scp...
|
||||
[timestamp] Pulled N snapshots successfully (total X MB, latest: ...)
|
||||
[timestamp] === backup complete ===
|
||||
```
|
||||
|
||||
Target directory: `C:\Users\antoi\Documents\ATOCore_Backups\snapshots\`
|
||||
Logs: `C:\Users\antoi\Documents\ATOCore_Backups\_logs\backup-*.log`
|
||||
|
||||
## Register the Task Scheduler task
|
||||
|
||||
### Option A — automatic registration (recommended)
|
||||
|
||||
Run this PowerShell command **as your user** (no admin needed — uses HKCU task):
|
||||
|
||||
```powershell
|
||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
|
||||
-Argument '-ExecutionPolicy Bypass -NonInteractive -WindowStyle Hidden -File C:\Users\antoi\ATOCore\scripts\windows\atocore-backup-pull.ps1'
|
||||
|
||||
# Run daily at 10:00 local time; if missed (computer off), run at next logon
|
||||
$trigger = New-ScheduledTaskTrigger -Daily -At 10:00AM
|
||||
$trigger.StartBoundary = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss')
|
||||
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries `
|
||||
-StartWhenAvailable `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) `
|
||||
-RestartCount 2 `
|
||||
-RestartInterval (New-TimeSpan -Minutes 30)
|
||||
|
||||
Register-ScheduledTask -TaskName 'AtoCore Backup Pull' `
|
||||
-Description 'Daily pull of AtoCore backup snapshots from Dalidou' `
|
||||
-Action $action -Trigger $trigger -Settings $settings `
|
||||
-User $env:USERNAME
|
||||
```
|
||||
|
||||
Key settings:
|
||||
- `-StartWhenAvailable`: if the computer was off at 10:00, run as soon as it
|
||||
comes online
|
||||
- `-AllowStartIfOnBatteries`: works on laptop battery too
|
||||
- `-ExecutionTimeLimit 10min`: kill hung tasks
|
||||
- `-RestartCount 2`: retry twice if it fails (Dalidou temporarily unreachable)
|
||||
|
||||
### Option B -- Task Scheduler GUI
|
||||
|
||||
1. Open Task Scheduler (`taskschd.msc`)
|
||||
2. Create Basic Task -> name: `AtoCore Backup Pull`
|
||||
3. Trigger: Daily, 10:00 AM, recur every 1 day
|
||||
4. Action: Start a program
|
||||
- Program: `powershell.exe`
|
||||
- Arguments: `-ExecutionPolicy Bypass -NonInteractive -WindowStyle Hidden -File "C:\Users\antoi\ATOCore\scripts\windows\atocore-backup-pull.ps1"`
|
||||
5. Finish, then edit the task:
|
||||
- Settings tab: check "Run task as soon as possible after a scheduled start is missed"
|
||||
- Settings tab: "If the task fails, restart every 30 minutes, up to 2 times"
|
||||
- Conditions tab: uncheck "Start only if computer is on AC power" (if you want it on battery)
|
||||
|
||||
## Verify
|
||||
|
||||
After the first scheduled run:
|
||||
|
||||
```powershell
|
||||
# Most recent log
|
||||
Get-ChildItem C:\Users\antoi\Documents\ATOCore_Backups\_logs\ |
|
||||
Sort-Object Name -Descending |
|
||||
Select-Object -First 1 |
|
||||
Get-Content
|
||||
|
||||
# Latest snapshot present?
|
||||
Get-ChildItem C:\Users\antoi\Documents\ATOCore_Backups\snapshots\ |
|
||||
Sort-Object Name -Descending |
|
||||
Select-Object -First 3
|
||||
```
|
||||
|
||||
## Unregister (if needed)
|
||||
|
||||
```powershell
|
||||
Unregister-ScheduledTask -TaskName 'AtoCore Backup Pull' -Confirm:$false
|
||||
```
|
||||
|
||||
## How it behaves
|
||||
|
||||
- **Computer on, Dalidou reachable**: pulls latest snapshots silently in ~15s
|
||||
- **Computer on, Dalidou unreachable** (remote work, network down): fail-open,
|
||||
exits without error, logs "Dalidou unreachable"
|
||||
- **Computer off at scheduled time**: Task Scheduler runs it as soon as the
|
||||
computer wakes up
|
||||
- **Many days off**: one run catches up; scp only transfers files not already
|
||||
present (snapshots are date-stamped directories, idempotent overwrites)
|
||||
|
||||
## What gets backed up
|
||||
|
||||
The snapshots tree contains:
|
||||
- `YYYYMMDDTHHMMSSZ/config/` — project registry, AtoCore config
|
||||
- `YYYYMMDDTHHMMSSZ/db/` — SQLite snapshot of all memory, state, interactions
|
||||
- `YYYYMMDDTHHMMSSZ/backup-metadata.json` — SHA, timestamp, source info
|
||||
|
||||
Chroma vectors are **not** in the snapshot by default
|
||||
(`ATOCORE_BACKUP_CHROMA=false` on Dalidou). They can be rebuilt from the
|
||||
source documents if lost. To include them, set `ATOCORE_BACKUP_CHROMA=true`
|
||||
in the Dalidou cron environment.
|
||||
|
||||
## Three-tier backup summary
|
||||
|
||||
After this setup:
|
||||
|
||||
| Tier | Location | Cadence | Purpose |
|
||||
|---|---|---|---|
|
||||
| Live | Dalidou `/srv/storage/atocore/backups/snapshots/` | Nightly 03:00 UTC | Fast restore |
|
||||
| Off-host | T420 `papa@192.168.86.39:/home/papa/atocore-backups/` | Nightly after Dalidou | Dalidou dies |
|
||||
| User machine | `C:\Users\antoi\Documents\ATOCore_Backups\` | Daily 10:00 local | Full home-network failure |
|
||||
|
||||
Three independent copies. Any two can be lost simultaneously without data loss.
|
||||
40
openclaw-plugins/atocore-capture/README.md
Normal file
40
openclaw-plugins/atocore-capture/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# AtoCore Capture + Context Plugin for OpenClaw
|
||||
|
||||
Two-way bridge between OpenClaw agents and AtoCore:
|
||||
|
||||
**Capture (since v1)**
|
||||
- watches user-triggered assistant turns
|
||||
- POSTs `prompt` + `response` to `POST /interactions`
|
||||
- sets `client="openclaw"`, `reinforce=true`
|
||||
- fails open on network or API errors
|
||||
|
||||
**Context injection (Phase 7I, v2+)**
|
||||
- on `before_agent_start`, fetches a context pack from `POST /context/build`
|
||||
- prepends the pack to the agent's prompt so whatever LLM runs underneath
|
||||
(sonnet, opus, codex, local model — whichever OpenClaw delegates to)
|
||||
answers grounded in what AtoCore already knows
|
||||
- original user prompt is still what gets captured later (no recursion)
|
||||
- fails open: context unreachable → agent runs as before
|
||||
|
||||
## Config
|
||||
|
||||
```json
|
||||
{
|
||||
"baseUrl": "http://dalidou:8100",
|
||||
"minPromptLength": 15,
|
||||
"maxResponseLength": 50000,
|
||||
"injectContext": true,
|
||||
"contextCharBudget": 4000
|
||||
}
|
||||
```
|
||||
|
||||
- `baseUrl` — defaults to `ATOCORE_BASE_URL` env or `http://dalidou:8100`
|
||||
- `injectContext` — set to `false` to disable the Phase 7I context injection and make this a pure one-way capture plugin again
|
||||
- `contextCharBudget` — cap on injected context size. `/context/build` respects it too; this is a client-side safety net. Default 4000 chars (~1000 tokens).
|
||||
|
||||
## Notes
|
||||
|
||||
- Project detection is intentionally left empty — AtoCore's extraction pipeline handles unscoped interactions and infers the project from content.
|
||||
- Extraction is **not** part of this plugin. Interactions are captured; batch extraction runs via cron on the AtoCore host.
|
||||
- Context injection only fires for user-triggered turns (not heartbeats or system-only runs).
|
||||
- Timeouts: context fetch is 5s (short so a slow AtoCore never blocks a user turn); capture post is 10s.
|
||||
154
openclaw-plugins/atocore-capture/handler.js
Normal file
154
openclaw-plugins/atocore-capture/handler.js
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* AtoCore OpenClaw plugin — capture + pull.
|
||||
*
|
||||
* Two responsibilities:
|
||||
*
|
||||
* 1. CAPTURE (existing): On before_agent_start, buffer the user prompt.
|
||||
* On llm_output, POST prompt+response to AtoCore /interactions.
|
||||
* This is the "write" side — OpenClaw turns feed AtoCore's memory.
|
||||
*
|
||||
* 2. PULL (Phase 1 master brain): On before_prompt_build, call AtoCore
|
||||
* /context/build and inject the returned context via prependContext.
|
||||
* Every OpenClaw response is automatically grounded in what AtoCore
|
||||
* knows (project state, memories, relevant chunks).
|
||||
*
|
||||
* Fail-open throughout: AtoCore unreachable = no injection, no capture,
|
||||
* never blocks the agent.
|
||||
*/
|
||||
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
|
||||
const BASE_URL = process.env.ATOCORE_BASE_URL || "http://dalidou:8100";
|
||||
const MIN_LEN = 15;
|
||||
const MAX_RESP = 50000;
|
||||
const CONTEXT_TIMEOUT_MS = 6000;
|
||||
const CAPTURE_TIMEOUT_MS = 10000;
|
||||
|
||||
function trim(v) { return typeof v === "string" ? v.trim() : ""; }
|
||||
function trunc(t, m) { return !t || t.length <= m ? t : t.slice(0, m) + "\n\n[truncated]"; }
|
||||
|
||||
function detectProject(prompt) {
|
||||
const lower = (prompt || "").toLowerCase();
|
||||
const hints = [
|
||||
["p04", "p04-gigabit"],
|
||||
["gigabit", "p04-gigabit"],
|
||||
["p05", "p05-interferometer"],
|
||||
["interferometer", "p05-interferometer"],
|
||||
["p06", "p06-polisher"],
|
||||
["polisher", "p06-polisher"],
|
||||
["fullum", "p06-polisher"],
|
||||
["abb", "abb-space"],
|
||||
["atomizer", "atomizer-v2"],
|
||||
["atocore", "atocore"],
|
||||
];
|
||||
for (const [token, proj] of hints) {
|
||||
if (lower.includes(token)) return proj;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
register(api) {
|
||||
const log = api.logger;
|
||||
let lastPrompt = null;
|
||||
|
||||
// --- PULL: inject AtoCore context into every prompt ---
|
||||
api.on("before_prompt_build", async (event, ctx) => {
|
||||
if (process.env.ATOCORE_PULL_DISABLED === "1") return;
|
||||
const prompt = trim(event?.prompt || "");
|
||||
if (prompt.length < MIN_LEN) return;
|
||||
|
||||
const project = detectProject(prompt);
|
||||
|
||||
try {
|
||||
const res = await fetch(BASE_URL.replace(/\/$/, "") + "/context/build", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, project }),
|
||||
signal: AbortSignal.timeout(CONTEXT_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
log.info("atocore-pull:http_error", { status: res.status });
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const contextPack = data.formatted_context || "";
|
||||
if (!contextPack.trim()) return;
|
||||
|
||||
log.info("atocore-pull:injected", {
|
||||
project: project || "(none)",
|
||||
chars: contextPack.length,
|
||||
});
|
||||
|
||||
return {
|
||||
prependContext:
|
||||
"--- AtoCore Context (auto-injected) ---\n" +
|
||||
contextPack +
|
||||
"\n--- End AtoCore Context ---\n",
|
||||
};
|
||||
} catch (err) {
|
||||
log.info("atocore-pull:error", { error: String(err).slice(0, 200) });
|
||||
}
|
||||
});
|
||||
|
||||
// --- CAPTURE: buffer user prompts on agent start ---
|
||||
api.on("before_agent_start", async (event, ctx) => {
|
||||
const prompt = trim(event?.prompt || event?.cleanedBody || "");
|
||||
if (prompt.length < MIN_LEN || prompt.startsWith("<")) {
|
||||
lastPrompt = null;
|
||||
return;
|
||||
}
|
||||
// Filter cron-initiated agent runs. OpenClaw's scheduled tasks fire
|
||||
// agent sessions with prompts that begin "[cron:<id> ...]". These are
|
||||
// automated polls (DXF email watcher, calendar reminders, etc.), not
|
||||
// real user turns — they're pure noise in the AtoCore capture stream.
|
||||
if (prompt.startsWith("[cron:")) {
|
||||
lastPrompt = null;
|
||||
return;
|
||||
}
|
||||
lastPrompt = { text: prompt, sessionKey: ctx?.sessionKey || "", ts: Date.now() };
|
||||
log.info("atocore-capture:prompt_buffered", { len: prompt.length });
|
||||
});
|
||||
|
||||
// --- CAPTURE: send completed turns to AtoCore ---
|
||||
api.on("llm_output", async (event, ctx) => {
|
||||
if (!lastPrompt) return;
|
||||
const texts = Array.isArray(event?.assistantTexts) ? event.assistantTexts : [];
|
||||
const response = trunc(trim(texts.join("\n\n")), MAX_RESP);
|
||||
if (!response) return;
|
||||
|
||||
const prompt = lastPrompt.text;
|
||||
const sessionKey = lastPrompt.sessionKey || ctx?.sessionKey || "";
|
||||
const project = detectProject(prompt);
|
||||
lastPrompt = null;
|
||||
|
||||
log.info("atocore-capture:posting", {
|
||||
promptLen: prompt.length,
|
||||
responseLen: response.length,
|
||||
project: project || "(none)",
|
||||
});
|
||||
|
||||
fetch(BASE_URL.replace(/\/$/, "") + "/interactions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
response,
|
||||
client: "openclaw",
|
||||
session_id: sessionKey,
|
||||
project,
|
||||
reinforce: true,
|
||||
}),
|
||||
signal: AbortSignal.timeout(CAPTURE_TIMEOUT_MS),
|
||||
}).then(res => {
|
||||
log.info("atocore-capture:posted", { status: res.status });
|
||||
}).catch(err => {
|
||||
log.warn("atocore-capture:post_error", { error: String(err).slice(0, 200) });
|
||||
});
|
||||
});
|
||||
|
||||
api.on("session_end", async () => {
|
||||
lastPrompt = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
152
openclaw-plugins/atocore-capture/index.js
Normal file
152
openclaw-plugins/atocore-capture/index.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
|
||||
const DEFAULT_BASE_URL = process.env.ATOCORE_BASE_URL || "http://dalidou:8100";
|
||||
const DEFAULT_MIN_PROMPT_LENGTH = 15;
|
||||
const DEFAULT_MAX_RESPONSE_LENGTH = 50_000;
|
||||
// Phase 7I — context injection: cap how much AtoCore context we stuff
|
||||
// back into the prompt. The /context/build endpoint respects a budget
|
||||
// parameter too, but we keep a client-side safety net.
|
||||
const DEFAULT_CONTEXT_CHAR_BUDGET = 4_000;
|
||||
const DEFAULT_INJECT_CONTEXT = true;
|
||||
|
||||
function trimText(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function truncateResponse(text, maxLength) {
|
||||
if (!text || text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength)}\n\n[truncated]`;
|
||||
}
|
||||
|
||||
function shouldCapturePrompt(prompt, minLength) {
|
||||
const text = trimText(prompt);
|
||||
if (!text) return false;
|
||||
if (text.startsWith("<")) return false;
|
||||
return text.length >= minLength;
|
||||
}
|
||||
|
||||
async function postInteraction(baseUrl, payload, logger) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/interactions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger?.debug?.("atocore_capture_post_failed", { status: res.status });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger?.debug?.("atocore_capture_post_error", {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 7I — fetch a context pack for the incoming prompt so the agent
|
||||
// answers grounded in what AtoCore already knows. Fail-open: if the
|
||||
// request times out or errors, we just don't inject; the agent runs as
|
||||
// before. Never block the user's turn on AtoCore availability.
|
||||
async function fetchContextPack(baseUrl, prompt, project, charBudget, logger) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/context/build`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
project: project || "",
|
||||
char_budget: charBudget
|
||||
}),
|
||||
signal: AbortSignal.timeout(5_000)
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger?.debug?.("atocore_context_fetch_failed", { status: res.status });
|
||||
return null;
|
||||
}
|
||||
const data = await res.json();
|
||||
const pack = trimText(data?.formatted_context || "");
|
||||
return pack || null;
|
||||
} catch (error) {
|
||||
logger?.debug?.("atocore_context_fetch_error", {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
register(api) {
|
||||
const logger = api.logger;
|
||||
const pendingBySession = new Map();
|
||||
|
||||
api.on("before_agent_start", async (event, ctx) => {
|
||||
if (ctx?.trigger && ctx.trigger !== "user") return;
|
||||
const config = api.getConfig?.() || {};
|
||||
const minPromptLength = Number(config.minPromptLength || DEFAULT_MIN_PROMPT_LENGTH);
|
||||
const prompt = trimText(event?.prompt || "");
|
||||
if (!shouldCapturePrompt(prompt, minPromptLength)) {
|
||||
pendingBySession.delete(ctx.sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 7I — inject AtoCore context into the agent's prompt so it
|
||||
// answers grounded in what the brain already knows. Config-gated
|
||||
// (injectContext: false disables). Fail-open.
|
||||
const baseUrl = trimText(config.baseUrl) || DEFAULT_BASE_URL;
|
||||
const injectContext = config.injectContext !== false && DEFAULT_INJECT_CONTEXT;
|
||||
const charBudget = Number(config.contextCharBudget || DEFAULT_CONTEXT_CHAR_BUDGET);
|
||||
if (injectContext && event && typeof event === "object") {
|
||||
const pack = await fetchContextPack(baseUrl, prompt, "", charBudget, logger);
|
||||
if (pack) {
|
||||
// Prepend to the event's prompt so the agent sees grounded info
|
||||
// before the user's question. OpenClaw's agent receives
|
||||
// event.prompt as its primary input; modifying it here grounds
|
||||
// whatever LLM the agent delegates to (sonnet, opus, codex,
|
||||
// local model — doesn't matter).
|
||||
event.prompt = `${pack}\n\n---\n\n${prompt}`;
|
||||
logger?.debug?.("atocore_context_injected", { chars: pack.length });
|
||||
}
|
||||
}
|
||||
|
||||
// Record the ORIGINAL user prompt (not the injected version) so
|
||||
// captured interactions stay clean for later extraction.
|
||||
pendingBySession.set(ctx.sessionId, {
|
||||
prompt,
|
||||
sessionId: ctx.sessionId,
|
||||
sessionKey: ctx.sessionKey || "",
|
||||
project: ""
|
||||
});
|
||||
});
|
||||
|
||||
api.on("llm_output", async (event, ctx) => {
|
||||
if (ctx?.trigger && ctx.trigger !== "user") return;
|
||||
const pending = pendingBySession.get(ctx.sessionId);
|
||||
if (!pending) return;
|
||||
|
||||
const assistantTexts = Array.isArray(event?.assistantTexts) ? event.assistantTexts : [];
|
||||
const response = truncateResponse(trimText(assistantTexts.join("\n\n")), Number((api.getConfig?.() || {}).maxResponseLength || DEFAULT_MAX_RESPONSE_LENGTH));
|
||||
if (!response) return;
|
||||
|
||||
const config = api.getConfig?.() || {};
|
||||
const baseUrl = trimText(config.baseUrl) || DEFAULT_BASE_URL;
|
||||
const payload = {
|
||||
prompt: pending.prompt,
|
||||
response,
|
||||
client: "openclaw",
|
||||
session_id: pending.sessionKey || pending.sessionId,
|
||||
project: pending.project || "",
|
||||
reinforce: true
|
||||
};
|
||||
|
||||
await postInteraction(baseUrl, payload, logger);
|
||||
pendingBySession.delete(ctx.sessionId);
|
||||
});
|
||||
|
||||
api.on("session_end", async (event) => {
|
||||
if (event?.sessionId) pendingBySession.delete(event.sessionId);
|
||||
});
|
||||
}
|
||||
});
|
||||
29
openclaw-plugins/atocore-capture/openclaw.plugin.json
Normal file
29
openclaw-plugins/atocore-capture/openclaw.plugin.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "atocore-capture",
|
||||
"name": "AtoCore Capture",
|
||||
"description": "Captures completed OpenClaw assistant turns to AtoCore interactions for reinforcement.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"description": "Override AtoCore base URL. Defaults to ATOCORE_BASE_URL or http://dalidou:8100"
|
||||
},
|
||||
"minPromptLength": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Minimum user prompt length required before capture"
|
||||
},
|
||||
"maxResponseLength": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"description": "Maximum assistant response length to store"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"uiHints": {
|
||||
"category": "automation",
|
||||
"displayName": "AtoCore Capture"
|
||||
}
|
||||
}
|
||||
7
openclaw-plugins/atocore-capture/package.json
Normal file
7
openclaw-plugins/atocore-capture/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@atomaste/atocore-openclaw-capture",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "OpenClaw plugin: captures assistant turns to AtoCore interactions AND injects AtoCore context into agent prompts before they run (Phase 7I two-way bridge)"
|
||||
}
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "atocore"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "Personal context engine for LLM interactions"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"pydantic>=2.6.0",
|
||||
"pydantic-settings>=2.1.0",
|
||||
"structlog>=24.1.0",
|
||||
"markdown>=3.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -6,3 +6,4 @@ sentence-transformers>=2.5.0
|
||||
pydantic>=2.6.0
|
||||
pydantic-settings>=2.1.0
|
||||
structlog>=24.1.0
|
||||
markdown>=3.5.0
|
||||
|
||||
630
scripts/atocore_client.py
Normal file
630
scripts/atocore_client.py
Normal file
@@ -0,0 +1,630 @@
|
||||
"""Operator-facing API client for live AtoCore instances.
|
||||
|
||||
This script is intentionally external to the app runtime. It is for admins
|
||||
and operators who want a convenient way to inspect live project state,
|
||||
refresh projects, audit retrieval quality, manage trusted project-state
|
||||
entries, and drive the Phase 9 reflection loop (capture, extract, queue,
|
||||
promote, reject).
|
||||
|
||||
Environment variables
|
||||
---------------------
|
||||
|
||||
ATOCORE_BASE_URL
|
||||
Base URL of the AtoCore service (default: ``http://dalidou:8100``).
|
||||
|
||||
When running ON the Dalidou host itself or INSIDE the Dalidou
|
||||
container, override this with loopback or the real IP::
|
||||
|
||||
ATOCORE_BASE_URL=http://127.0.0.1:8100 \\
|
||||
python scripts/atocore_client.py health
|
||||
|
||||
The default hostname "dalidou" is meant for cases where the
|
||||
caller is a remote machine (laptop, T420/OpenClaw, etc.) with
|
||||
"dalidou" in its /etc/hosts or resolvable via Tailscale. It does
|
||||
NOT reliably resolve on the host itself or inside the container,
|
||||
and when it fails the client returns
|
||||
``{"status": "unavailable", "fail_open": true}`` — the right
|
||||
diagnosis when that happens is to set ATOCORE_BASE_URL explicitly
|
||||
to 127.0.0.1:8100 and retry.
|
||||
|
||||
ATOCORE_TIMEOUT_SECONDS
|
||||
Request timeout for most operations (default: 30).
|
||||
|
||||
ATOCORE_REFRESH_TIMEOUT_SECONDS
|
||||
Longer timeout for project refresh operations which can be slow
|
||||
(default: 1800).
|
||||
|
||||
ATOCORE_FAIL_OPEN
|
||||
When "true" (default), network errors return a small fail-open
|
||||
envelope instead of raising. Set to "false" for admin operations
|
||||
where you need the real error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://dalidou:8100").rstrip("/")
|
||||
TIMEOUT = int(os.environ.get("ATOCORE_TIMEOUT_SECONDS", "30"))
|
||||
REFRESH_TIMEOUT = int(os.environ.get("ATOCORE_REFRESH_TIMEOUT_SECONDS", "1800"))
|
||||
FAIL_OPEN = os.environ.get("ATOCORE_FAIL_OPEN", "true").lower() == "true"
|
||||
|
||||
# Bumped when the subcommand surface or JSON output shapes meaningfully
|
||||
# change. See docs/architecture/llm-client-integration.md for the
|
||||
# semver rules. History:
|
||||
# 0.1.0 initial stable-ops-only client
|
||||
# 0.2.0 Phase 9 reflection loop added: capture, extract,
|
||||
# reinforce-interaction, list-interactions, get-interaction,
|
||||
# queue, promote, reject
|
||||
CLIENT_VERSION = "0.2.0"
|
||||
|
||||
|
||||
def print_json(payload: Any) -> None:
|
||||
print(json.dumps(payload, ensure_ascii=True, indent=2))
|
||||
|
||||
|
||||
def fail_open_payload() -> dict[str, Any]:
|
||||
return {"status": "unavailable", "source": "atocore", "fail_open": True}
|
||||
|
||||
|
||||
def request(
|
||||
method: str,
|
||||
path: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
timeout: int | None = None,
|
||||
) -> Any:
|
||||
url = f"{BASE_URL}{path}"
|
||||
headers = {"Content-Type": "application/json"} if data is not None else {}
|
||||
payload = json.dumps(data).encode("utf-8") if data is not None else None
|
||||
req = urllib.request.Request(url, data=payload, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout or TIMEOUT) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8")
|
||||
if body:
|
||||
print(body)
|
||||
raise SystemExit(22) from exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
if FAIL_OPEN:
|
||||
print_json(fail_open_payload())
|
||||
raise SystemExit(0)
|
||||
raise
|
||||
|
||||
if not body.strip():
|
||||
return {}
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
def parse_aliases(aliases_csv: str) -> list[str]:
|
||||
return [alias.strip() for alias in aliases_csv.split(",") if alias.strip()]
|
||||
|
||||
|
||||
def detect_project(prompt: str) -> dict[str, Any]:
|
||||
payload = request("GET", "/projects")
|
||||
prompt_lower = prompt.lower()
|
||||
best_project = None
|
||||
best_alias = None
|
||||
best_score = -1
|
||||
|
||||
for project in payload.get("projects", []):
|
||||
candidates = [project.get("id", ""), *project.get("aliases", [])]
|
||||
for candidate in candidates:
|
||||
candidate = (candidate or "").strip()
|
||||
if not candidate:
|
||||
continue
|
||||
pattern = rf"(?<![a-z0-9]){re.escape(candidate.lower())}(?![a-z0-9])"
|
||||
matched = re.search(pattern, prompt_lower) is not None
|
||||
if not matched and candidate.lower() not in prompt_lower:
|
||||
continue
|
||||
score = len(candidate)
|
||||
if score > best_score:
|
||||
best_project = project.get("id")
|
||||
best_alias = candidate
|
||||
best_score = score
|
||||
|
||||
return {"matched_project": best_project, "matched_alias": best_alias}
|
||||
|
||||
|
||||
def classify_result(result: dict[str, Any]) -> dict[str, Any]:
|
||||
source_file = (result.get("source_file") or "").lower()
|
||||
heading = (result.get("heading_path") or "").lower()
|
||||
title = (result.get("title") or "").lower()
|
||||
text = " ".join([source_file, heading, title])
|
||||
|
||||
labels: list[str] = []
|
||||
if any(token in text for token in ["_archive", "/archive", "archive/", "pre-cleanup", "pre-migration", "history"]):
|
||||
labels.append("archive_or_history")
|
||||
if any(token in text for token in ["status", "dashboard", "current-state", "current state", "next-steps", "next steps"]):
|
||||
labels.append("current_status")
|
||||
if any(token in text for token in ["decision", "adr", "tradeoff", "selected architecture", "selection"]):
|
||||
labels.append("decision")
|
||||
if any(token in text for token in ["requirement", "spec", "constraints", "baseline", "cdr", "sow"]):
|
||||
labels.append("requirements")
|
||||
if any(token in text for token in ["roadmap", "milestone", "plan", "workflow", "calibration", "contract"]):
|
||||
labels.append("execution_plan")
|
||||
if not labels:
|
||||
labels.append("reference")
|
||||
|
||||
return {
|
||||
"score": result.get("score"),
|
||||
"title": result.get("title"),
|
||||
"heading_path": result.get("heading_path"),
|
||||
"source_file": result.get("source_file"),
|
||||
"labels": labels,
|
||||
"is_noise_risk": "archive_or_history" in labels,
|
||||
}
|
||||
|
||||
|
||||
def audit_query(prompt: str, top_k: int, project: str | None) -> dict[str, Any]:
|
||||
response = request(
|
||||
"POST",
|
||||
"/query",
|
||||
{"prompt": prompt, "top_k": top_k, "project": project or None},
|
||||
)
|
||||
classifications = [classify_result(result) for result in response.get("results", [])]
|
||||
broad_prompt = len(prompt.split()) <= 2
|
||||
noise_hits = sum(1 for item in classifications if item["is_noise_risk"])
|
||||
current_hits = sum(1 for item in classifications if "current_status" in item["labels"])
|
||||
decision_hits = sum(1 for item in classifications if "decision" in item["labels"])
|
||||
requirements_hits = sum(1 for item in classifications if "requirements" in item["labels"])
|
||||
|
||||
recommendations: list[str] = []
|
||||
if broad_prompt:
|
||||
recommendations.append("Prompt is broad; prefer a project-specific question with intent, artifact type, or constraint language.")
|
||||
if noise_hits:
|
||||
recommendations.append("Archive/history noise is present; prefer current-status, decision, requirements, and baseline docs in the next ingestion/ranking pass.")
|
||||
if current_hits == 0:
|
||||
recommendations.append("No current-status docs surfaced in the top results; Wave 2 should ingest or strengthen trusted operational truth.")
|
||||
if decision_hits == 0:
|
||||
recommendations.append("No decision docs surfaced in the top results; add or freeze decision logs for the active project.")
|
||||
if requirements_hits == 0:
|
||||
recommendations.append("No requirements/baseline docs surfaced in the top results; prioritize baseline and architecture-freeze material.")
|
||||
if not recommendations:
|
||||
recommendations.append("Ranking looks healthy for this prompt.")
|
||||
|
||||
return {
|
||||
"prompt": prompt,
|
||||
"project": project,
|
||||
"top_k": top_k,
|
||||
"broad_prompt": broad_prompt,
|
||||
"noise_hits": noise_hits,
|
||||
"current_status_hits": current_hits,
|
||||
"decision_hits": decision_hits,
|
||||
"requirements_hits": requirements_hits,
|
||||
"results": classifications,
|
||||
"recommendations": recommendations,
|
||||
}
|
||||
|
||||
|
||||
def project_payload(
|
||||
project_id: str,
|
||||
aliases_csv: str,
|
||||
source: str,
|
||||
subpath: str,
|
||||
description: str,
|
||||
label: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"aliases": parse_aliases(aliases_csv),
|
||||
"description": description,
|
||||
"ingest_roots": [{"source": source, "subpath": subpath, "label": label}],
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="AtoCore live API client")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
for name in ["health", "sources", "stats", "projects", "project-template", "debug-context", "ingest-sources"]:
|
||||
sub.add_parser(name)
|
||||
|
||||
p = sub.add_parser("detect-project")
|
||||
p.add_argument("prompt")
|
||||
|
||||
p = sub.add_parser("auto-context")
|
||||
p.add_argument("prompt")
|
||||
p.add_argument("budget", nargs="?", type=int, default=3000)
|
||||
p.add_argument("project", nargs="?", default="")
|
||||
|
||||
for name in ["propose-project", "register-project"]:
|
||||
p = sub.add_parser(name)
|
||||
p.add_argument("project_id")
|
||||
p.add_argument("aliases_csv")
|
||||
p.add_argument("source")
|
||||
p.add_argument("subpath")
|
||||
p.add_argument("description", nargs="?", default="")
|
||||
p.add_argument("label", nargs="?", default="")
|
||||
|
||||
p = sub.add_parser("update-project")
|
||||
p.add_argument("project")
|
||||
p.add_argument("description")
|
||||
p.add_argument("aliases_csv", nargs="?", default="")
|
||||
|
||||
p = sub.add_parser("refresh-project")
|
||||
p.add_argument("project")
|
||||
p.add_argument("purge_deleted", nargs="?", default="false")
|
||||
|
||||
p = sub.add_parser("project-state")
|
||||
p.add_argument("project")
|
||||
p.add_argument("category", nargs="?", default="")
|
||||
|
||||
p = sub.add_parser("project-state-set")
|
||||
p.add_argument("project")
|
||||
p.add_argument("category")
|
||||
p.add_argument("key")
|
||||
p.add_argument("value")
|
||||
p.add_argument("source", nargs="?", default="")
|
||||
p.add_argument("confidence", nargs="?", type=float, default=1.0)
|
||||
|
||||
p = sub.add_parser("project-state-invalidate")
|
||||
p.add_argument("project")
|
||||
p.add_argument("category")
|
||||
p.add_argument("key")
|
||||
|
||||
p = sub.add_parser("query")
|
||||
p.add_argument("prompt")
|
||||
p.add_argument("top_k", nargs="?", type=int, default=5)
|
||||
p.add_argument("project", nargs="?", default="")
|
||||
|
||||
p = sub.add_parser("context-build")
|
||||
p.add_argument("prompt")
|
||||
p.add_argument("project", nargs="?", default="")
|
||||
p.add_argument("budget", nargs="?", type=int, default=3000)
|
||||
|
||||
p = sub.add_parser("audit-query")
|
||||
p.add_argument("prompt")
|
||||
p.add_argument("top_k", nargs="?", type=int, default=5)
|
||||
p.add_argument("project", nargs="?", default="")
|
||||
|
||||
# --- Phase 9 reflection loop surface --------------------------------
|
||||
#
|
||||
# capture: record one interaction (prompt + response + context used).
|
||||
# Mirrors POST /interactions. response is positional so shell
|
||||
# callers can pass it via $(cat file.txt) or heredoc. project,
|
||||
# client, and session_id are optional positionals with empty
|
||||
# defaults, matching the existing script's style.
|
||||
p = sub.add_parser("capture")
|
||||
p.add_argument("prompt")
|
||||
p.add_argument("response", nargs="?", default="")
|
||||
p.add_argument("project", nargs="?", default="")
|
||||
p.add_argument("client", nargs="?", default="")
|
||||
p.add_argument("session_id", nargs="?", default="")
|
||||
p.add_argument("reinforce", nargs="?", default="true")
|
||||
|
||||
# extract: run the Phase 9 C rule-based extractor against an
|
||||
# already-captured interaction. persist='true' writes the
|
||||
# candidates as status='candidate' memories; default is
|
||||
# preview-only.
|
||||
p = sub.add_parser("extract")
|
||||
p.add_argument("interaction_id")
|
||||
p.add_argument("persist", nargs="?", default="false")
|
||||
|
||||
# reinforce: backfill reinforcement on an already-captured interaction.
|
||||
p = sub.add_parser("reinforce-interaction")
|
||||
p.add_argument("interaction_id")
|
||||
|
||||
# list-interactions: paginated listing with filters.
|
||||
p = sub.add_parser("list-interactions")
|
||||
p.add_argument("project", nargs="?", default="")
|
||||
p.add_argument("session_id", nargs="?", default="")
|
||||
p.add_argument("client", nargs="?", default="")
|
||||
p.add_argument("since", nargs="?", default="")
|
||||
p.add_argument("limit", nargs="?", type=int, default=50)
|
||||
|
||||
# get-interaction: fetch one by id
|
||||
p = sub.add_parser("get-interaction")
|
||||
p.add_argument("interaction_id")
|
||||
|
||||
# queue: list the candidate review queue
|
||||
p = sub.add_parser("queue")
|
||||
p.add_argument("memory_type", nargs="?", default="")
|
||||
p.add_argument("project", nargs="?", default="")
|
||||
p.add_argument("limit", nargs="?", type=int, default=50)
|
||||
|
||||
# promote: candidate -> active
|
||||
p = sub.add_parser("promote")
|
||||
p.add_argument("memory_id")
|
||||
|
||||
# reject: candidate -> invalid
|
||||
p = sub.add_parser("reject")
|
||||
p.add_argument("memory_id")
|
||||
|
||||
# batch-extract: fan out /interactions/{id}/extract?persist=true across
|
||||
# recent interactions. Idempotent — the extractor create_memory path
|
||||
# silently skips duplicates, so re-running is safe.
|
||||
p = sub.add_parser("batch-extract")
|
||||
p.add_argument("since", nargs="?", default="")
|
||||
p.add_argument("project", nargs="?", default="")
|
||||
p.add_argument("limit", nargs="?", type=int, default=100)
|
||||
p.add_argument("persist", nargs="?", default="true")
|
||||
|
||||
# triage: interactive candidate review loop. Fetches the queue, shows
|
||||
# each candidate, accepts p/r/s (promote / reject / skip) / q (quit).
|
||||
p = sub.add_parser("triage")
|
||||
p.add_argument("memory_type", nargs="?", default="")
|
||||
p.add_argument("project", nargs="?", default="")
|
||||
p.add_argument("limit", nargs="?", type=int, default=50)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
cmd = args.command
|
||||
|
||||
if cmd == "health":
|
||||
print_json(request("GET", "/health"))
|
||||
elif cmd == "sources":
|
||||
print_json(request("GET", "/sources"))
|
||||
elif cmd == "stats":
|
||||
print_json(request("GET", "/stats"))
|
||||
elif cmd == "projects":
|
||||
print_json(request("GET", "/projects"))
|
||||
elif cmd == "project-template":
|
||||
print_json(request("GET", "/projects/template"))
|
||||
elif cmd == "debug-context":
|
||||
print_json(request("GET", "/debug/context"))
|
||||
elif cmd == "ingest-sources":
|
||||
print_json(request("POST", "/ingest/sources", {}))
|
||||
elif cmd == "detect-project":
|
||||
print_json(detect_project(args.prompt))
|
||||
elif cmd == "auto-context":
|
||||
project = args.project or detect_project(args.prompt).get("matched_project") or ""
|
||||
if not project:
|
||||
print_json({"status": "no_project_match", "source": "atocore", "mode": "auto-context"})
|
||||
else:
|
||||
print_json(request("POST", "/context/build", {"prompt": args.prompt, "project": project, "budget": args.budget}))
|
||||
elif cmd in {"propose-project", "register-project"}:
|
||||
path = "/projects/proposal" if cmd == "propose-project" else "/projects/register"
|
||||
print_json(request("POST", path, project_payload(args.project_id, args.aliases_csv, args.source, args.subpath, args.description, args.label)))
|
||||
elif cmd == "update-project":
|
||||
payload: dict[str, Any] = {"description": args.description}
|
||||
if args.aliases_csv.strip():
|
||||
payload["aliases"] = parse_aliases(args.aliases_csv)
|
||||
print_json(request("PUT", f"/projects/{urllib.parse.quote(args.project)}", payload))
|
||||
elif cmd == "refresh-project":
|
||||
purge_deleted = args.purge_deleted.lower() in {"1", "true", "yes", "y"}
|
||||
path = f"/projects/{urllib.parse.quote(args.project)}/refresh?purge_deleted={str(purge_deleted).lower()}"
|
||||
print_json(request("POST", path, {}, timeout=REFRESH_TIMEOUT))
|
||||
elif cmd == "project-state":
|
||||
suffix = f"?category={urllib.parse.quote(args.category)}" if args.category else ""
|
||||
print_json(request("GET", f"/project/state/{urllib.parse.quote(args.project)}{suffix}"))
|
||||
elif cmd == "project-state-set":
|
||||
print_json(request("POST", "/project/state", {
|
||||
"project": args.project,
|
||||
"category": args.category,
|
||||
"key": args.key,
|
||||
"value": args.value,
|
||||
"source": args.source,
|
||||
"confidence": args.confidence,
|
||||
}))
|
||||
elif cmd == "project-state-invalidate":
|
||||
print_json(request("DELETE", "/project/state", {"project": args.project, "category": args.category, "key": args.key}))
|
||||
elif cmd == "query":
|
||||
print_json(request("POST", "/query", {"prompt": args.prompt, "top_k": args.top_k, "project": args.project or None}))
|
||||
elif cmd == "context-build":
|
||||
print_json(request("POST", "/context/build", {"prompt": args.prompt, "project": args.project or None, "budget": args.budget}))
|
||||
elif cmd == "audit-query":
|
||||
print_json(audit_query(args.prompt, args.top_k, args.project or None))
|
||||
# --- Phase 9 reflection loop surface ------------------------------
|
||||
elif cmd == "capture":
|
||||
body: dict[str, Any] = {
|
||||
"prompt": args.prompt,
|
||||
"response": args.response,
|
||||
"project": args.project,
|
||||
"client": args.client or "atocore-client",
|
||||
"session_id": args.session_id,
|
||||
"reinforce": args.reinforce.lower() in {"1", "true", "yes", "y"},
|
||||
}
|
||||
print_json(request("POST", "/interactions", body))
|
||||
elif cmd == "extract":
|
||||
persist = args.persist.lower() in {"1", "true", "yes", "y"}
|
||||
print_json(
|
||||
request(
|
||||
"POST",
|
||||
f"/interactions/{urllib.parse.quote(args.interaction_id, safe='')}/extract",
|
||||
{"persist": persist},
|
||||
)
|
||||
)
|
||||
elif cmd == "reinforce-interaction":
|
||||
print_json(
|
||||
request(
|
||||
"POST",
|
||||
f"/interactions/{urllib.parse.quote(args.interaction_id, safe='')}/reinforce",
|
||||
{},
|
||||
)
|
||||
)
|
||||
elif cmd == "list-interactions":
|
||||
query_parts: list[str] = []
|
||||
if args.project:
|
||||
query_parts.append(f"project={urllib.parse.quote(args.project)}")
|
||||
if args.session_id:
|
||||
query_parts.append(f"session_id={urllib.parse.quote(args.session_id)}")
|
||||
if args.client:
|
||||
query_parts.append(f"client={urllib.parse.quote(args.client)}")
|
||||
if args.since:
|
||||
query_parts.append(f"since={urllib.parse.quote(args.since)}")
|
||||
query_parts.append(f"limit={int(args.limit)}")
|
||||
query = "?" + "&".join(query_parts)
|
||||
print_json(request("GET", f"/interactions{query}"))
|
||||
elif cmd == "get-interaction":
|
||||
print_json(
|
||||
request(
|
||||
"GET",
|
||||
f"/interactions/{urllib.parse.quote(args.interaction_id, safe='')}",
|
||||
)
|
||||
)
|
||||
elif cmd == "queue":
|
||||
query_parts = ["status=candidate"]
|
||||
if args.memory_type:
|
||||
query_parts.append(f"memory_type={urllib.parse.quote(args.memory_type)}")
|
||||
if args.project:
|
||||
query_parts.append(f"project={urllib.parse.quote(args.project)}")
|
||||
query_parts.append(f"limit={int(args.limit)}")
|
||||
query = "?" + "&".join(query_parts)
|
||||
print_json(request("GET", f"/memory{query}"))
|
||||
elif cmd == "promote":
|
||||
print_json(
|
||||
request(
|
||||
"POST",
|
||||
f"/memory/{urllib.parse.quote(args.memory_id, safe='')}/promote",
|
||||
{},
|
||||
)
|
||||
)
|
||||
elif cmd == "reject":
|
||||
print_json(
|
||||
request(
|
||||
"POST",
|
||||
f"/memory/{urllib.parse.quote(args.memory_id, safe='')}/reject",
|
||||
{},
|
||||
)
|
||||
)
|
||||
elif cmd == "batch-extract":
|
||||
print_json(run_batch_extract(args.since, args.project, args.limit, args.persist))
|
||||
elif cmd == "triage":
|
||||
return run_triage(args.memory_type, args.project, args.limit)
|
||||
else:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def run_batch_extract(since: str, project: str, limit: int, persist_flag: str) -> dict:
|
||||
"""Fetch recent interactions and run the extractor against each one.
|
||||
|
||||
Returns an aggregated summary. Safe to re-run: the server-side
|
||||
persist path catches ValueError on duplicates and the endpoint
|
||||
reports per-interaction candidate counts either way.
|
||||
"""
|
||||
persist = persist_flag.lower() in {"1", "true", "yes", "y"}
|
||||
query_parts: list[str] = []
|
||||
if project:
|
||||
query_parts.append(f"project={urllib.parse.quote(project)}")
|
||||
if since:
|
||||
query_parts.append(f"since={urllib.parse.quote(since)}")
|
||||
query_parts.append(f"limit={int(limit)}")
|
||||
query = "?" + "&".join(query_parts)
|
||||
|
||||
listing = request("GET", f"/interactions{query}")
|
||||
interactions = listing.get("interactions", []) if isinstance(listing, dict) else []
|
||||
|
||||
processed = 0
|
||||
total_candidates = 0
|
||||
total_persisted = 0
|
||||
errors: list[dict] = []
|
||||
per_interaction: list[dict] = []
|
||||
|
||||
for item in interactions:
|
||||
iid = item.get("id") or ""
|
||||
if not iid:
|
||||
continue
|
||||
try:
|
||||
result = request(
|
||||
"POST",
|
||||
f"/interactions/{urllib.parse.quote(iid, safe='')}/extract",
|
||||
{"persist": persist},
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - network errors land here
|
||||
errors.append({"interaction_id": iid, "error": str(exc)})
|
||||
continue
|
||||
processed += 1
|
||||
count = int(result.get("candidate_count", 0) or 0)
|
||||
persisted_ids = result.get("persisted_ids") or []
|
||||
total_candidates += count
|
||||
total_persisted += len(persisted_ids)
|
||||
if count:
|
||||
per_interaction.append(
|
||||
{
|
||||
"interaction_id": iid,
|
||||
"candidate_count": count,
|
||||
"persisted_count": len(persisted_ids),
|
||||
"project": item.get("project") or "",
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"processed": processed,
|
||||
"total_candidates": total_candidates,
|
||||
"total_persisted": total_persisted,
|
||||
"persist": persist,
|
||||
"errors": errors,
|
||||
"interactions_with_candidates": per_interaction,
|
||||
}
|
||||
|
||||
|
||||
def run_triage(memory_type: str, project: str, limit: int) -> int:
|
||||
"""Interactive review of candidate memories.
|
||||
|
||||
Loads the queue once, walks through entries, prompts for
|
||||
(p)romote / (r)eject / (s)kip / (q)uit. Stateless between runs —
|
||||
re-running picks up whatever is still status=candidate.
|
||||
"""
|
||||
query_parts = ["status=candidate"]
|
||||
if memory_type:
|
||||
query_parts.append(f"memory_type={urllib.parse.quote(memory_type)}")
|
||||
if project:
|
||||
query_parts.append(f"project={urllib.parse.quote(project)}")
|
||||
query_parts.append(f"limit={int(limit)}")
|
||||
listing = request("GET", "/memory?" + "&".join(query_parts))
|
||||
memories = listing.get("memories", []) if isinstance(listing, dict) else []
|
||||
|
||||
if not memories:
|
||||
print_json({"status": "empty_queue", "count": 0})
|
||||
return 0
|
||||
|
||||
promoted = 0
|
||||
rejected = 0
|
||||
skipped = 0
|
||||
stopped_early = False
|
||||
|
||||
print(f"Triage queue: {len(memories)} candidate(s)\n", file=sys.stderr)
|
||||
for idx, mem in enumerate(memories, 1):
|
||||
mid = mem.get("id", "")
|
||||
print(f"[{idx}/{len(memories)}] {mem.get('memory_type','?')} project={mem.get('project','')} conf={mem.get('confidence','?')}", file=sys.stderr)
|
||||
print(f" id: {mid}", file=sys.stderr)
|
||||
print(f" {mem.get('content','')}", file=sys.stderr)
|
||||
try:
|
||||
choice = input(" (p)romote / (r)eject / (s)kip / (q)uit > ").strip().lower()
|
||||
except EOFError:
|
||||
stopped_early = True
|
||||
break
|
||||
if choice in {"q", "quit"}:
|
||||
stopped_early = True
|
||||
break
|
||||
if choice in {"p", "promote"}:
|
||||
request("POST", f"/memory/{urllib.parse.quote(mid, safe='')}/promote", {})
|
||||
promoted += 1
|
||||
print(" -> promoted", file=sys.stderr)
|
||||
elif choice in {"r", "reject"}:
|
||||
request("POST", f"/memory/{urllib.parse.quote(mid, safe='')}/reject", {})
|
||||
rejected += 1
|
||||
print(" -> rejected", file=sys.stderr)
|
||||
else:
|
||||
skipped += 1
|
||||
print(" -> skipped", file=sys.stderr)
|
||||
|
||||
print_json(
|
||||
{
|
||||
"reviewed": promoted + rejected + skipped,
|
||||
"promoted": promoted,
|
||||
"rejected": rejected,
|
||||
"skipped": skipped,
|
||||
"stopped_early": stopped_early,
|
||||
"remaining_in_queue": len(memories) - (promoted + rejected + skipped) - (1 if stopped_early else 0),
|
||||
}
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
914
scripts/atocore_mcp.py
Normal file
914
scripts/atocore_mcp.py
Normal file
@@ -0,0 +1,914 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AtoCore MCP server — stdio transport, stdlib-only.
|
||||
|
||||
Exposes the AtoCore HTTP API as MCP tools so any MCP-aware client
|
||||
(Claude Desktop, Claude Code, Cursor, Zed, Windsurf) can pull
|
||||
context + memories automatically at prompt time.
|
||||
|
||||
Design:
|
||||
- stdlib only (no mcp SDK dep) — MCP protocol is simple JSON-RPC
|
||||
over stdio, and AtoCore's philosophy prefers stdlib.
|
||||
- Thin wrapper: every tool is a direct pass-through to an HTTP
|
||||
endpoint. Zero business logic here — the AtoCore server is
|
||||
the single source of truth.
|
||||
- Fail-open: if AtoCore is unreachable, tools return a graceful
|
||||
"unavailable" message rather than crashing the client.
|
||||
|
||||
Protocol: MCP 2024-11-05 / 2025-03-26 compatible
|
||||
https://spec.modelcontextprotocol.io/specification/
|
||||
|
||||
Usage (standalone test):
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}' | python atocore_mcp.py
|
||||
|
||||
Register with Claude Code:
|
||||
claude mcp add atocore -- python /path/to/atocore_mcp.py
|
||||
|
||||
Environment:
|
||||
ATOCORE_URL base URL of the AtoCore HTTP API (default http://dalidou:8100)
|
||||
ATOCORE_TIMEOUT per-request HTTP timeout seconds (default 10)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
# Force UTF-8 on stdio — MCP protocol expects UTF-8 but Windows Python
|
||||
# defaults stdout to cp1252, which crashes on any non-ASCII char (emojis,
|
||||
# ≥, →, etc.) in tool responses. This call is a no-op on Linux/macOS
|
||||
# where UTF-8 is already the default.
|
||||
try:
|
||||
sys.stdin.reconfigure(encoding="utf-8")
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
ATOCORE_URL = os.environ.get("ATOCORE_URL", "http://dalidou:8100").rstrip("/")
|
||||
HTTP_TIMEOUT = float(os.environ.get("ATOCORE_TIMEOUT", "10"))
|
||||
SERVER_NAME = "atocore"
|
||||
SERVER_VERSION = "0.1.0"
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
|
||||
|
||||
# --- stderr logging (stdout is reserved for JSON-RPC) ---
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[atocore-mcp] {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
# --- HTTP helpers ---
|
||||
|
||||
def http_get(path: str, params: dict | None = None) -> dict:
|
||||
"""GET a JSON response from AtoCore. Raises on HTTP error."""
|
||||
url = ATOCORE_URL + path
|
||||
if params:
|
||||
# Drop empty params so the URL stays clean
|
||||
clean = {k: v for k, v in params.items() if v not in (None, "", [], {})}
|
||||
if clean:
|
||||
url += "?" + urllib.parse.urlencode(clean)
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def http_post(path: str, body: dict) -> dict:
|
||||
url = ATOCORE_URL + path
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=data, method="POST",
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def safe_call(fn, *args, **kwargs) -> tuple[dict | None, str | None]:
|
||||
"""Run an HTTP call, return (result, error_message_or_None)."""
|
||||
try:
|
||||
return fn(*args, **kwargs), None
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
body = ""
|
||||
return None, f"AtoCore HTTP {e.code}: {body[:200]}"
|
||||
except urllib.error.URLError as e:
|
||||
return None, f"AtoCore unreachable at {ATOCORE_URL}: {e.reason}"
|
||||
except Exception as e:
|
||||
return None, f"AtoCore error: {type(e).__name__}: {str(e)[:200]}"
|
||||
|
||||
|
||||
# --- Tool definitions ---
|
||||
# Each tool: name, description, inputSchema (JSON Schema), handler
|
||||
|
||||
def _tool_context(args: dict) -> str:
|
||||
"""Build a full context pack for a query — state + memories + retrieved chunks."""
|
||||
query = (args.get("query") or "").strip()
|
||||
project = args.get("project") or ""
|
||||
if not query:
|
||||
return "Error: 'query' is required."
|
||||
result, err = safe_call(http_post, "/context/build", {
|
||||
"prompt": query, "project": project,
|
||||
})
|
||||
if err:
|
||||
return f"AtoCore context unavailable: {err}"
|
||||
pack = result.get("formatted_context", "") or ""
|
||||
if not pack.strip():
|
||||
return "(AtoCore returned an empty context pack — no matching state, memories, or chunks.)"
|
||||
return pack
|
||||
|
||||
|
||||
def _tool_search(args: dict) -> str:
|
||||
"""Retrieval only — raw chunks ranked by semantic similarity."""
|
||||
query = (args.get("query") or "").strip()
|
||||
project = args.get("project") or ""
|
||||
top_k = int(args.get("top_k") or 5)
|
||||
if not query:
|
||||
return "Error: 'query' is required."
|
||||
result, err = safe_call(http_post, "/query", {
|
||||
"prompt": query, "project": project, "top_k": top_k,
|
||||
})
|
||||
if err:
|
||||
return f"AtoCore search unavailable: {err}"
|
||||
chunks = result.get("results", []) or []
|
||||
if not chunks:
|
||||
return "No results."
|
||||
lines = []
|
||||
for i, c in enumerate(chunks, 1):
|
||||
src = c.get("source_file") or c.get("title") or "unknown"
|
||||
heading = c.get("heading_path") or ""
|
||||
snippet = (c.get("content") or "")[:300]
|
||||
score = c.get("score", 0.0)
|
||||
head_str = f" ({heading})" if heading else ""
|
||||
lines.append(f"[{i}] score={score:.3f} source={src}{head_str}\n{snippet}")
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def _tool_memory_list(args: dict) -> str:
|
||||
"""List active memories, optionally filtered by project and type."""
|
||||
params = {
|
||||
"status": "active",
|
||||
"limit": int(args.get("limit") or 20),
|
||||
}
|
||||
if args.get("project"):
|
||||
params["project"] = args["project"]
|
||||
if args.get("memory_type"):
|
||||
params["memory_type"] = args["memory_type"]
|
||||
result, err = safe_call(http_get, "/memory", params=params)
|
||||
if err:
|
||||
return f"AtoCore memory list unavailable: {err}"
|
||||
memories = result.get("memories", []) or []
|
||||
if not memories:
|
||||
return "No memories match."
|
||||
lines = []
|
||||
for m in memories:
|
||||
mt = m.get("memory_type", "?")
|
||||
proj = m.get("project") or "(global)"
|
||||
conf = m.get("confidence", 0.0)
|
||||
refs = m.get("reference_count", 0)
|
||||
content = (m.get("content") or "")[:250]
|
||||
lines.append(f"[{mt}/{proj}] conf={conf:.2f} refs={refs}\n {content}")
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def _tool_memory_create(args: dict) -> str:
|
||||
"""Create a candidate memory (enters the triage queue)."""
|
||||
memory_type = (args.get("memory_type") or "").strip()
|
||||
content = (args.get("content") or "").strip()
|
||||
project = args.get("project") or ""
|
||||
confidence = float(args.get("confidence") or 0.5)
|
||||
if not memory_type or not content:
|
||||
return "Error: 'memory_type' and 'content' are required."
|
||||
valid_types = ["identity", "preference", "project", "episodic", "knowledge", "adaptation"]
|
||||
if memory_type not in valid_types:
|
||||
return f"Error: memory_type must be one of {valid_types}."
|
||||
result, err = safe_call(http_post, "/memory", {
|
||||
"memory_type": memory_type,
|
||||
"content": content,
|
||||
"project": project,
|
||||
"confidence": confidence,
|
||||
"status": "candidate",
|
||||
})
|
||||
if err:
|
||||
return f"AtoCore memory create failed: {err}"
|
||||
mid = result.get("id", "?")
|
||||
return f"Candidate memory created: id={mid} type={memory_type} project={project or '(global)'}"
|
||||
|
||||
|
||||
def _tool_project_state(args: dict) -> str:
|
||||
"""Get Trusted Project State entries for a project."""
|
||||
project = (args.get("project") or "").strip()
|
||||
category = args.get("category") or ""
|
||||
if not project:
|
||||
return "Error: 'project' is required."
|
||||
path = f"/project/state/{urllib.parse.quote(project)}"
|
||||
params = {"category": category} if category else None
|
||||
result, err = safe_call(http_get, path, params=params)
|
||||
if err:
|
||||
return f"AtoCore project state unavailable: {err}"
|
||||
entries = result.get("entries", []) or result.get("state", []) or []
|
||||
if not entries:
|
||||
return f"No state entries for project '{project}'."
|
||||
lines = []
|
||||
for e in entries:
|
||||
cat = e.get("category", "?")
|
||||
key = e.get("key", "?")
|
||||
value = (e.get("value") or "")[:300]
|
||||
src = e.get("source") or ""
|
||||
lines.append(f"[{cat}/{key}] (source: {src})\n {value}")
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def _tool_projects(args: dict) -> str:
|
||||
"""List registered AtoCore projects."""
|
||||
result, err = safe_call(http_get, "/projects")
|
||||
if err:
|
||||
return f"AtoCore projects unavailable: {err}"
|
||||
projects = result.get("projects", []) or []
|
||||
if not projects:
|
||||
return "No projects registered."
|
||||
lines = []
|
||||
for p in projects:
|
||||
pid = p.get("project_id") or p.get("id") or p.get("name") or "?"
|
||||
aliases = p.get("aliases", []) or []
|
||||
alias_str = f" (aliases: {', '.join(aliases)})" if aliases else ""
|
||||
lines.append(f"- {pid}{alias_str}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _tool_remember(args: dict) -> str:
|
||||
"""Phase 6 Part B — universal capture from any Claude session.
|
||||
|
||||
Wraps POST /memory to create a candidate memory tagged with
|
||||
source='mcp-remember'. The existing 3-tier triage is the quality
|
||||
gate: nothing becomes active until sonnet (+ opus if borderline)
|
||||
approves it. Returns the memory id so the caller can reference it
|
||||
in the same session.
|
||||
"""
|
||||
content = (args.get("content") or "").strip()
|
||||
if not content:
|
||||
return "Error: 'content' is required."
|
||||
|
||||
memory_type = (args.get("memory_type") or "knowledge").strip()
|
||||
valid_types = ["identity", "preference", "project", "episodic", "knowledge", "adaptation"]
|
||||
if memory_type not in valid_types:
|
||||
return f"Error: memory_type must be one of {valid_types}."
|
||||
|
||||
project = (args.get("project") or "").strip()
|
||||
try:
|
||||
confidence = float(args.get("confidence") or 0.6)
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.6
|
||||
confidence = max(0.0, min(1.0, confidence))
|
||||
|
||||
valid_until = (args.get("valid_until") or "").strip()
|
||||
tags = args.get("domain_tags") or []
|
||||
if not isinstance(tags, list):
|
||||
tags = []
|
||||
# Normalize tags: lowercase, dedupe, cap at 10
|
||||
clean_tags: list[str] = []
|
||||
for t in tags[:10]:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
t = t.strip().lower()
|
||||
if t and t not in clean_tags:
|
||||
clean_tags.append(t)
|
||||
|
||||
payload = {
|
||||
"memory_type": memory_type,
|
||||
"content": content,
|
||||
"project": project,
|
||||
"confidence": confidence,
|
||||
"status": "candidate",
|
||||
}
|
||||
if valid_until:
|
||||
payload["valid_until"] = valid_until
|
||||
if clean_tags:
|
||||
payload["domain_tags"] = clean_tags
|
||||
|
||||
result, err = safe_call(http_post, "/memory", payload)
|
||||
if err:
|
||||
return f"AtoCore remember failed: {err}"
|
||||
|
||||
mid = result.get("id", "?")
|
||||
scope = project if project else "(global)"
|
||||
tag_str = f" tags=[{', '.join(clean_tags)}]" if clean_tags else ""
|
||||
expires = f" valid_until={valid_until}" if valid_until else ""
|
||||
return (
|
||||
f"Remembered as candidate: id={mid}\n"
|
||||
f" type={memory_type} project={scope} confidence={confidence:.2f}{tag_str}{expires}\n"
|
||||
f"Will flow through the standard triage pipeline within 24h "
|
||||
f"(or on next auto-process button click at /admin/triage)."
|
||||
)
|
||||
|
||||
|
||||
def _tool_health(args: dict) -> str:
|
||||
"""Check AtoCore service health."""
|
||||
result, err = safe_call(http_get, "/health")
|
||||
if err:
|
||||
return f"AtoCore unreachable: {err}"
|
||||
sha = result.get("build_sha", "?")[:8]
|
||||
vectors = result.get("vectors_count", "?")
|
||||
env = result.get("env", "?")
|
||||
return f"AtoCore healthy: sha={sha} vectors={vectors} env={env}"
|
||||
|
||||
|
||||
# --- Phase 5H: Engineering query tools ---
|
||||
|
||||
|
||||
def _tool_system_map(args: dict) -> str:
|
||||
"""Q-001 + Q-004: subsystem/component tree for a project."""
|
||||
project = (args.get("project") or "").strip()
|
||||
if not project:
|
||||
return "Error: 'project' is required."
|
||||
result, err = safe_call(
|
||||
http_get, f"/engineering/projects/{urllib.parse.quote(project)}/systems"
|
||||
)
|
||||
if err:
|
||||
return f"Engineering query failed: {err}"
|
||||
subs = result.get("subsystems", []) or []
|
||||
orphans = result.get("orphan_components", []) or []
|
||||
if not subs and not orphans:
|
||||
return f"No subsystems or components registered for {project}."
|
||||
lines = [f"System map for {project}:"]
|
||||
for s in subs:
|
||||
lines.append(f"\n[{s['name']}] — {s.get('description') or '(no description)'}")
|
||||
for c in s.get("components", []):
|
||||
mats = ", ".join(c.get("materials", [])) or "-"
|
||||
lines.append(f" • {c['name']} (materials: {mats})")
|
||||
if orphans:
|
||||
lines.append(f"\nOrphan components (not attached to any subsystem):")
|
||||
for c in orphans:
|
||||
lines.append(f" • {c['name']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _tool_gaps(args: dict) -> str:
|
||||
"""Q-006 + Q-009 + Q-011: find coverage gaps. Director's most-used query."""
|
||||
project = (args.get("project") or "").strip()
|
||||
if not project:
|
||||
return "Error: 'project' is required."
|
||||
result, err = safe_call(
|
||||
http_get, f"/engineering/gaps",
|
||||
params={"project": project},
|
||||
)
|
||||
if err:
|
||||
return f"Gap query failed: {err}"
|
||||
|
||||
orphan = result.get("orphan_requirements", {})
|
||||
risky = result.get("risky_decisions", {})
|
||||
unsup = result.get("unsupported_claims", {})
|
||||
|
||||
counts = f"{orphan.get('count',0)}/{risky.get('count',0)}/{unsup.get('count',0)}"
|
||||
lines = [f"Coverage gaps for {project} (orphan reqs / risky decisions / unsupported claims: {counts}):\n"]
|
||||
|
||||
if orphan.get("count", 0):
|
||||
lines.append(f"ORPHAN REQUIREMENTS ({orphan['count']}) — no component claims to satisfy:")
|
||||
for g in orphan.get("gaps", [])[:10]:
|
||||
lines.append(f" • {g['name']}: {(g.get('description') or '')[:120]}")
|
||||
lines.append("")
|
||||
if risky.get("count", 0):
|
||||
lines.append(f"RISKY DECISIONS ({risky['count']}) — based on flagged assumptions:")
|
||||
for g in risky.get("gaps", [])[:10]:
|
||||
lines.append(f" • {g['decision_name']} (assumption: {g['assumption_name']} — {g['assumption_status']})")
|
||||
lines.append("")
|
||||
if unsup.get("count", 0):
|
||||
lines.append(f"UNSUPPORTED CLAIMS ({unsup['count']}) — no Result entity backs them:")
|
||||
for g in unsup.get("gaps", [])[:10]:
|
||||
lines.append(f" • {g['name']}: {(g.get('description') or '')[:120]}")
|
||||
|
||||
if orphan.get("count", 0) == 0 and risky.get("count", 0) == 0 and unsup.get("count", 0) == 0:
|
||||
lines.append("✓ No gaps detected — every requirement satisfied, no flagged assumptions, all claims have evidence.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _tool_requirements_for(args: dict) -> str:
|
||||
"""Q-005: requirements that a component satisfies."""
|
||||
component_id = (args.get("component_id") or "").strip()
|
||||
if not component_id:
|
||||
return "Error: 'component_id' is required."
|
||||
result, err = safe_call(
|
||||
http_get, f"/engineering/components/{urllib.parse.quote(component_id)}/requirements"
|
||||
)
|
||||
if err:
|
||||
return f"Query failed: {err}"
|
||||
reqs = result.get("requirements", []) or []
|
||||
if not reqs:
|
||||
return "No requirements associated with this component."
|
||||
lines = [f"Component satisfies {len(reqs)} requirement(s):"]
|
||||
for r in reqs:
|
||||
lines.append(f" • {r['name']}: {(r.get('description') or '')[:150]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _tool_decisions_affecting(args: dict) -> str:
|
||||
"""Q-008: decisions affecting a project or subsystem."""
|
||||
project = (args.get("project") or "").strip()
|
||||
subsystem = args.get("subsystem_id") or args.get("subsystem") or ""
|
||||
if not project:
|
||||
return "Error: 'project' is required."
|
||||
params = {"project": project}
|
||||
if subsystem:
|
||||
params["subsystem"] = subsystem
|
||||
result, err = safe_call(http_get, "/engineering/decisions", params=params)
|
||||
if err:
|
||||
return f"Query failed: {err}"
|
||||
decisions = result.get("decisions", []) or []
|
||||
if not decisions:
|
||||
scope = f"subsystem {subsystem}" if subsystem else f"project {project}"
|
||||
return f"No decisions recorded for {scope}."
|
||||
scope = f"subsystem {subsystem}" if subsystem else project
|
||||
lines = [f"{len(decisions)} decision(s) affecting {scope}:"]
|
||||
for d in decisions:
|
||||
lines.append(f" • {d['name']}: {(d.get('description') or '')[:150]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _tool_recent_changes(args: dict) -> str:
|
||||
"""Q-013: what changed recently in the engineering graph."""
|
||||
project = (args.get("project") or "").strip()
|
||||
since = args.get("since") or ""
|
||||
limit = int(args.get("limit") or 20)
|
||||
if not project:
|
||||
return "Error: 'project' is required."
|
||||
params = {"project": project, "limit": limit}
|
||||
if since:
|
||||
params["since"] = since
|
||||
result, err = safe_call(http_get, "/engineering/changes", params=params)
|
||||
if err:
|
||||
return f"Query failed: {err}"
|
||||
changes = result.get("changes", []) or []
|
||||
if not changes:
|
||||
return f"No entity changes in {project} since {since or '(all time)'}."
|
||||
lines = [f"Recent changes in {project} ({len(changes)}):"]
|
||||
for c in changes:
|
||||
lines.append(
|
||||
f" [{c['timestamp'][:16]}] {c['action']:10s} "
|
||||
f"[{c.get('entity_type','?')}] {c.get('entity_name','?')} "
|
||||
f"by {c.get('actor','?')}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _tool_impact(args: dict) -> str:
|
||||
"""Q-016: impact of changing an entity (downstream BFS)."""
|
||||
entity = (args.get("entity_id") or args.get("entity") or "").strip()
|
||||
if not entity:
|
||||
return "Error: 'entity_id' is required."
|
||||
max_depth = int(args.get("max_depth") or 3)
|
||||
result, err = safe_call(
|
||||
http_get, "/engineering/impact",
|
||||
params={"entity": entity, "max_depth": max_depth},
|
||||
)
|
||||
if err:
|
||||
return f"Query failed: {err}"
|
||||
root = result.get("root") or {}
|
||||
impacted = result.get("impacted", []) or []
|
||||
if not impacted:
|
||||
return f"Nothing downstream of [{root.get('entity_type','?')}] {root.get('name','?')}."
|
||||
lines = [
|
||||
f"Changing [{root.get('entity_type')}] {root.get('name')} "
|
||||
f"would affect {len(impacted)} entity(ies) (max depth {max_depth}):"
|
||||
]
|
||||
for i in impacted[:25]:
|
||||
indent = " " * i.get("depth", 1)
|
||||
lines.append(f"{indent}→ [{i['entity_type']}] {i['name']} (via {i['relationship']})")
|
||||
if len(impacted) > 25:
|
||||
lines.append(f" ... and {len(impacted)-25} more")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _tool_evidence(args: dict) -> str:
|
||||
"""Q-017: evidence chain for an entity."""
|
||||
entity = (args.get("entity_id") or args.get("entity") or "").strip()
|
||||
if not entity:
|
||||
return "Error: 'entity_id' is required."
|
||||
result, err = safe_call(http_get, "/engineering/evidence", params={"entity": entity})
|
||||
if err:
|
||||
return f"Query failed: {err}"
|
||||
root = result.get("root") or {}
|
||||
chain = result.get("evidence_chain", []) or []
|
||||
lines = [f"Evidence for [{root.get('entity_type','?')}] {root.get('name','?')}:"]
|
||||
if not chain:
|
||||
lines.append(" (no inbound provenance edges)")
|
||||
else:
|
||||
for e in chain:
|
||||
lines.append(
|
||||
f" {e['via']} ← [{e['source_type']}] {e['source_name']}: "
|
||||
f"{(e.get('source_description') or '')[:100]}"
|
||||
)
|
||||
refs = result.get("direct_source_refs") or []
|
||||
if refs:
|
||||
lines.append(f"\nDirect source_refs: {refs[:5]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "atocore_context",
|
||||
"description": (
|
||||
"Get the full AtoCore context pack for a user query. Returns "
|
||||
"Trusted Project State (high trust), relevant memories, and "
|
||||
"retrieved source chunks formatted for prompt injection. "
|
||||
"Use this FIRST on any project-related query to ground the "
|
||||
"conversation in what AtoCore already knows."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "The user's question or task"},
|
||||
"project": {"type": "string", "description": "Project hint (e.g. 'p04-gigabit'); optional"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
"handler": _tool_context,
|
||||
},
|
||||
{
|
||||
"name": "atocore_search",
|
||||
"description": (
|
||||
"Semantic search over AtoCore's ingested source documents. "
|
||||
"Returns top-K ranked chunks. Use this when you need raw "
|
||||
"references rather than a full context pack."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"project": {"type": "string", "description": "optional project filter"},
|
||||
"top_k": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
"handler": _tool_search,
|
||||
},
|
||||
{
|
||||
"name": "atocore_memory_list",
|
||||
"description": (
|
||||
"List active memories (curated facts, decisions, preferences). "
|
||||
"Filter by project and/or memory_type. Use this to inspect what "
|
||||
"AtoCore currently remembers about a topic."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {"type": "string"},
|
||||
"memory_type": {
|
||||
"type": "string",
|
||||
"enum": ["identity", "preference", "project", "episodic", "knowledge", "adaptation"],
|
||||
},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
|
||||
},
|
||||
},
|
||||
"handler": _tool_memory_list,
|
||||
},
|
||||
{
|
||||
"name": "atocore_memory_create",
|
||||
"description": (
|
||||
"Propose a new memory for AtoCore. Creates a CANDIDATE that "
|
||||
"enters the triage queue for human/auto review — not immediately "
|
||||
"active. Use this to capture durable facts/decisions that "
|
||||
"should persist across sessions. Do NOT use for transient state "
|
||||
"or session-specific notes."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_type": {
|
||||
"type": "string",
|
||||
"enum": ["identity", "preference", "project", "episodic", "knowledge", "adaptation"],
|
||||
},
|
||||
"content": {"type": "string", "description": "The fact/decision/preference to remember"},
|
||||
"project": {"type": "string", "description": "project id if project-scoped; empty for global"},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1, "default": 0.5},
|
||||
},
|
||||
"required": ["memory_type", "content"],
|
||||
},
|
||||
"handler": _tool_memory_create,
|
||||
},
|
||||
{
|
||||
"name": "atocore_remember",
|
||||
"description": (
|
||||
"Save a durable fact to AtoCore's memory layer from any conversation. "
|
||||
"Use when the user says 'remember this', 'save that for later', "
|
||||
"'don't lose this fact', or when you identify a decision/insight/"
|
||||
"preference worth persisting across future sessions. The fact "
|
||||
"goes through quality review before being consulted in future "
|
||||
"context packs (so durable facts get kept, noise gets rejected). "
|
||||
"Call multiple times if one conversation has multiple distinct "
|
||||
"facts worth remembering — one tool call per atomic fact. "
|
||||
"Prefer 'knowledge' type for cross-project engineering insights, "
|
||||
"'project' for facts specific to one project, 'preference' for "
|
||||
"user work-style notes, 'adaptation' for standing behavioral rules."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The atomic fact to remember. Under 250 chars. Should stand alone without session context.",
|
||||
},
|
||||
"memory_type": {
|
||||
"type": "string",
|
||||
"enum": ["identity", "preference", "project", "episodic", "knowledge", "adaptation"],
|
||||
"default": "knowledge",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Project id if scoped. Empty for cross-project. Unregistered names flagged by triage as 'emerging project' proposals.",
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"default": 0.6,
|
||||
"description": "0.5-0.7 typical. 0.8+ only for ratified/committed claims.",
|
||||
},
|
||||
"valid_until": {
|
||||
"type": "string",
|
||||
"description": "ISO date YYYY-MM-DD if time-bounded (e.g. current state, scheduled event, quote expiry). Empty for permanent facts.",
|
||||
},
|
||||
"domain_tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Lowercase topical tags (optics, thermal, firmware, procurement, etc.) for cross-project retrieval. 2-5 tags typical.",
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
"handler": _tool_remember,
|
||||
},
|
||||
{
|
||||
"name": "atocore_project_state",
|
||||
"description": (
|
||||
"Get Trusted Project State entries for a given project — the "
|
||||
"highest-trust tier with curated decisions, requirements, "
|
||||
"facts, contacts, milestones. Use this to look up authoritative "
|
||||
"project info."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {"type": "string"},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["status", "decision", "requirement", "contact", "milestone", "fact", "config"],
|
||||
},
|
||||
},
|
||||
"required": ["project"],
|
||||
},
|
||||
"handler": _tool_project_state,
|
||||
},
|
||||
{
|
||||
"name": "atocore_projects",
|
||||
"description": "List all registered AtoCore projects (id + aliases).",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"handler": _tool_projects,
|
||||
},
|
||||
{
|
||||
"name": "atocore_health",
|
||||
"description": "Check AtoCore service health (build SHA, vector count, env).",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"handler": _tool_health,
|
||||
},
|
||||
# --- Phase 5H: Engineering knowledge graph tools ---
|
||||
{
|
||||
"name": "atocore_engineering_map",
|
||||
"description": (
|
||||
"Get the subsystem/component tree for an engineering project. "
|
||||
"Returns the full system architecture: subsystems, their components, "
|
||||
"materials, and any orphan components not attached to a subsystem. "
|
||||
"Use when the user asks about project structure or system design."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {"type": "string", "description": "Project id (e.g. p04-gigabit)"},
|
||||
},
|
||||
"required": ["project"],
|
||||
},
|
||||
"handler": _tool_system_map,
|
||||
},
|
||||
{
|
||||
"name": "atocore_engineering_gaps",
|
||||
"description": (
|
||||
"Find coverage gaps in a project's engineering graph: orphan "
|
||||
"requirements (no component satisfies them), risky decisions "
|
||||
"(based on flagged assumptions), and unsupported claims (no "
|
||||
"Result evidence). This is the director's most useful query — "
|
||||
"answers 'what am I forgetting?' in seconds."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {"type": "string"},
|
||||
},
|
||||
"required": ["project"],
|
||||
},
|
||||
"handler": _tool_gaps,
|
||||
},
|
||||
{
|
||||
"name": "atocore_engineering_requirements_for_component",
|
||||
"description": "List the requirements a specific component claims to satisfy (Q-005).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"component_id": {"type": "string"},
|
||||
},
|
||||
"required": ["component_id"],
|
||||
},
|
||||
"handler": _tool_requirements_for,
|
||||
},
|
||||
{
|
||||
"name": "atocore_engineering_decisions",
|
||||
"description": (
|
||||
"Decisions that affect a project, optionally scoped to a specific "
|
||||
"subsystem. Use when the user asks 'what did we decide about X?'"
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {"type": "string"},
|
||||
"subsystem_id": {"type": "string", "description": "optional subsystem entity id"},
|
||||
},
|
||||
"required": ["project"],
|
||||
},
|
||||
"handler": _tool_decisions_affecting,
|
||||
},
|
||||
{
|
||||
"name": "atocore_engineering_changes",
|
||||
"description": (
|
||||
"Recent changes to the engineering graph for a project: which "
|
||||
"entities were created/promoted/rejected/updated, by whom, when. "
|
||||
"Use for 'what changed recently?' type questions."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {"type": "string"},
|
||||
"since": {"type": "string", "description": "ISO timestamp; optional"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 20},
|
||||
},
|
||||
"required": ["project"],
|
||||
},
|
||||
"handler": _tool_recent_changes,
|
||||
},
|
||||
{
|
||||
"name": "atocore_engineering_impact",
|
||||
"description": (
|
||||
"Impact analysis: what's downstream of a given entity. BFS over "
|
||||
"outbound relationships up to max_depth. Use to answer 'what would "
|
||||
"break if I change X?'"
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entity_id": {"type": "string"},
|
||||
"max_depth": {"type": "integer", "minimum": 1, "maximum": 5, "default": 3},
|
||||
},
|
||||
"required": ["entity_id"],
|
||||
},
|
||||
"handler": _tool_impact,
|
||||
},
|
||||
{
|
||||
"name": "atocore_engineering_evidence",
|
||||
"description": (
|
||||
"Evidence chain for an entity: what supports it? Walks inbound "
|
||||
"SUPPORTS / EVIDENCED_BY / DESCRIBED_BY / VALIDATED_BY / ANALYZED_BY "
|
||||
"edges. Use for 'how do we know X is true?' type questions."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entity_id": {"type": "string"},
|
||||
},
|
||||
"required": ["entity_id"],
|
||||
},
|
||||
"handler": _tool_evidence,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# --- JSON-RPC handlers ---
|
||||
|
||||
def handle_initialize(params: dict) -> dict:
|
||||
return {
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"capabilities": {
|
||||
"tools": {"listChanged": False},
|
||||
},
|
||||
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
||||
}
|
||||
|
||||
|
||||
def handle_tools_list(params: dict) -> dict:
|
||||
return {
|
||||
"tools": [
|
||||
{"name": t["name"], "description": t["description"], "inputSchema": t["inputSchema"]}
|
||||
for t in TOOLS
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def handle_tools_call(params: dict) -> dict:
|
||||
tool_name = params.get("name", "")
|
||||
args = params.get("arguments", {}) or {}
|
||||
tool = next((t for t in TOOLS if t["name"] == tool_name), None)
|
||||
if tool is None:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}],
|
||||
"isError": True,
|
||||
}
|
||||
try:
|
||||
text = tool["handler"](args)
|
||||
except Exception as e:
|
||||
log(f"tool {tool_name} raised: {e}")
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Tool error: {type(e).__name__}: {e}"}],
|
||||
"isError": True,
|
||||
}
|
||||
return {"content": [{"type": "text", "text": text}]}
|
||||
|
||||
|
||||
def handle_ping(params: dict) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
METHODS = {
|
||||
"initialize": handle_initialize,
|
||||
"tools/list": handle_tools_list,
|
||||
"tools/call": handle_tools_call,
|
||||
"ping": handle_ping,
|
||||
}
|
||||
|
||||
|
||||
# --- stdio main loop ---
|
||||
|
||||
def send(obj: dict) -> None:
|
||||
"""Write a single-line JSON message to stdout and flush."""
|
||||
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def make_response(req_id, result=None, error=None) -> dict:
|
||||
resp = {"jsonrpc": "2.0", "id": req_id}
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
else:
|
||||
resp["result"] = result if result is not None else {}
|
||||
return resp
|
||||
|
||||
|
||||
def main() -> int:
|
||||
log(f"starting (AtoCore at {ATOCORE_URL})")
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError as e:
|
||||
log(f"parse error: {e}")
|
||||
continue
|
||||
|
||||
method = msg.get("method", "")
|
||||
req_id = msg.get("id")
|
||||
params = msg.get("params", {}) or {}
|
||||
|
||||
# Notifications (no id) don't need a response
|
||||
if req_id is None:
|
||||
if method == "notifications/initialized":
|
||||
log("client initialized")
|
||||
continue
|
||||
|
||||
handler = METHODS.get(method)
|
||||
if handler is None:
|
||||
send(make_response(req_id, error={
|
||||
"code": -32601,
|
||||
"message": f"Method not found: {method}",
|
||||
}))
|
||||
continue
|
||||
|
||||
try:
|
||||
result = handler(params)
|
||||
send(make_response(req_id, result=result))
|
||||
except Exception as e:
|
||||
log(f"handler {method} raised: {e}")
|
||||
send(make_response(req_id, error={
|
||||
"code": -32603,
|
||||
"message": f"Internal error: {type(e).__name__}: {e}",
|
||||
}))
|
||||
|
||||
log("stdin closed, exiting")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
321
scripts/atocore_proxy.py
Normal file
321
scripts/atocore_proxy.py
Normal file
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AtoCore Proxy — OpenAI-compatible HTTP middleware.
|
||||
|
||||
Acts as a drop-in layer for any client that speaks the OpenAI Chat
|
||||
Completions API (Codex, Ollama, LiteLLM, custom agents). Sits between
|
||||
the client and the real model provider:
|
||||
|
||||
client -> atocore_proxy -> real_provider (OpenAI, Ollama, Anthropic, ...)
|
||||
|
||||
For each chat completion request:
|
||||
1. Extract the user's last message as the "query"
|
||||
2. Call AtoCore /context/build to get a context pack
|
||||
3. Inject the pack as a system message (or prepend to existing system)
|
||||
4. Forward the enriched request to the real provider
|
||||
5. Capture the full interaction back to AtoCore /interactions
|
||||
|
||||
Fail-open: if AtoCore is unreachable, the request passes through
|
||||
unchanged. If the real provider fails, the error is propagated to the
|
||||
client as-is.
|
||||
|
||||
Configuration (env vars):
|
||||
ATOCORE_URL AtoCore base URL (default http://dalidou:8100)
|
||||
ATOCORE_UPSTREAM real provider base URL (e.g. http://localhost:11434/v1 for Ollama)
|
||||
ATOCORE_PROXY_PORT port to listen on (default 11435)
|
||||
ATOCORE_PROXY_HOST bind address (default 127.0.0.1)
|
||||
ATOCORE_CLIENT_LABEL client id recorded in captures (default "proxy")
|
||||
ATOCORE_CAPTURE "1" to capture interactions back (default "1")
|
||||
ATOCORE_INJECT "1" to inject context (default "1")
|
||||
|
||||
Usage:
|
||||
# Proxy for Ollama:
|
||||
ATOCORE_UPSTREAM=http://localhost:11434/v1 python atocore_proxy.py
|
||||
|
||||
# Then point your client at http://localhost:11435/v1 instead of the
|
||||
# real provider.
|
||||
|
||||
Stdlib only — deliberate to keep the dependency footprint at zero.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
ATOCORE_URL = os.environ.get("ATOCORE_URL", "http://dalidou:8100").rstrip("/")
|
||||
UPSTREAM_URL = os.environ.get("ATOCORE_UPSTREAM", "").rstrip("/")
|
||||
PROXY_PORT = int(os.environ.get("ATOCORE_PROXY_PORT", "11435"))
|
||||
PROXY_HOST = os.environ.get("ATOCORE_PROXY_HOST", "127.0.0.1")
|
||||
CLIENT_LABEL = os.environ.get("ATOCORE_CLIENT_LABEL", "proxy")
|
||||
CAPTURE_ENABLED = os.environ.get("ATOCORE_CAPTURE", "1") == "1"
|
||||
INJECT_ENABLED = os.environ.get("ATOCORE_INJECT", "1") == "1"
|
||||
ATOCORE_TIMEOUT = float(os.environ.get("ATOCORE_TIMEOUT", "6"))
|
||||
UPSTREAM_TIMEOUT = float(os.environ.get("ATOCORE_UPSTREAM_TIMEOUT", "300"))
|
||||
|
||||
PROJECT_HINTS = [
|
||||
("p04-gigabit", ["p04", "gigabit"]),
|
||||
("p05-interferometer", ["p05", "interferometer"]),
|
||||
("p06-polisher", ["p06", "polisher", "fullum"]),
|
||||
("abb-space", ["abb"]),
|
||||
("atomizer-v2", ["atomizer"]),
|
||||
("atocore", ["atocore", "dalidou"]),
|
||||
]
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[atocore-proxy] {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def detect_project(text: str) -> str:
|
||||
lower = (text or "").lower()
|
||||
for proj, tokens in PROJECT_HINTS:
|
||||
if any(t in lower for t in tokens):
|
||||
return proj
|
||||
return ""
|
||||
|
||||
|
||||
def get_last_user_message(body: dict) -> str:
|
||||
messages = body.get("messages", []) or []
|
||||
for m in reversed(messages):
|
||||
if m.get("role") == "user":
|
||||
content = m.get("content", "")
|
||||
if isinstance(content, list):
|
||||
# OpenAI multi-part content: extract text parts
|
||||
parts = [p.get("text", "") for p in content if p.get("type") == "text"]
|
||||
return "\n".join(parts)
|
||||
return str(content)
|
||||
return ""
|
||||
|
||||
|
||||
def get_assistant_text(response: dict) -> str:
|
||||
"""Extract assistant text from an OpenAI-style completion response."""
|
||||
choices = response.get("choices", []) or []
|
||||
if not choices:
|
||||
return ""
|
||||
msg = choices[0].get("message", {}) or {}
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
parts = [p.get("text", "") for p in content if p.get("type") == "text"]
|
||||
return "\n".join(parts)
|
||||
return str(content)
|
||||
|
||||
|
||||
def fetch_context(query: str, project: str) -> str:
|
||||
"""Pull a context pack from AtoCore. Returns '' on any failure."""
|
||||
if not INJECT_ENABLED or not query:
|
||||
return ""
|
||||
try:
|
||||
data = json.dumps({"prompt": query, "project": project}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
ATOCORE_URL + "/context/build",
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=ATOCORE_TIMEOUT) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
return result.get("formatted_context", "") or ""
|
||||
except Exception as e:
|
||||
log(f"context fetch failed: {type(e).__name__}: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def capture_interaction(prompt: str, response: str, project: str) -> None:
|
||||
"""POST the completed turn back to AtoCore. Fire-and-forget."""
|
||||
if not CAPTURE_ENABLED or not prompt or not response:
|
||||
return
|
||||
|
||||
def _post():
|
||||
try:
|
||||
data = json.dumps({
|
||||
"prompt": prompt,
|
||||
"response": response,
|
||||
"client": CLIENT_LABEL,
|
||||
"project": project,
|
||||
"reinforce": True,
|
||||
}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
ATOCORE_URL + "/interactions",
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=ATOCORE_TIMEOUT)
|
||||
except Exception as e:
|
||||
log(f"capture failed: {type(e).__name__}: {e}")
|
||||
|
||||
threading.Thread(target=_post, daemon=True).start()
|
||||
|
||||
|
||||
def inject_context(body: dict, context_pack: str) -> dict:
|
||||
"""Prepend the AtoCore context as a system message, or augment existing."""
|
||||
if not context_pack.strip():
|
||||
return body
|
||||
header = "--- AtoCore Context (auto-injected) ---\n"
|
||||
footer = "\n--- End AtoCore Context ---\n"
|
||||
injection = header + context_pack + footer
|
||||
|
||||
messages = list(body.get("messages", []) or [])
|
||||
if messages and messages[0].get("role") == "system":
|
||||
# Augment existing system message
|
||||
existing = messages[0].get("content", "") or ""
|
||||
if isinstance(existing, list):
|
||||
# multi-part: prepend a text part
|
||||
messages[0]["content"] = [{"type": "text", "text": injection}] + existing
|
||||
else:
|
||||
messages[0]["content"] = injection + "\n" + str(existing)
|
||||
else:
|
||||
messages.insert(0, {"role": "system", "content": injection})
|
||||
|
||||
body["messages"] = messages
|
||||
return body
|
||||
|
||||
|
||||
def forward_to_upstream(body: dict, headers: dict[str, str], path: str) -> tuple[int, dict]:
|
||||
"""Forward the enriched body to the upstream provider. Returns (status, response_dict)."""
|
||||
if not UPSTREAM_URL:
|
||||
return 503, {"error": {"message": "ATOCORE_UPSTREAM not configured"}}
|
||||
url = UPSTREAM_URL + path
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
# Strip hop-by-hop / host-specific headers
|
||||
fwd_headers = {"Content-Type": "application/json"}
|
||||
for k, v in headers.items():
|
||||
lk = k.lower()
|
||||
if lk in ("authorization", "x-api-key", "anthropic-version"):
|
||||
fwd_headers[k] = v
|
||||
req = urllib.request.Request(url, data=data, method="POST", headers=fwd_headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=UPSTREAM_TIMEOUT) as resp:
|
||||
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
body_bytes = e.read()
|
||||
payload = json.loads(body_bytes.decode("utf-8"))
|
||||
except Exception:
|
||||
payload = {"error": {"message": f"upstream HTTP {e.code}"}}
|
||||
return e.code, payload
|
||||
except Exception as e:
|
||||
log(f"upstream error: {e}")
|
||||
return 502, {"error": {"message": f"upstream unreachable: {e}"}}
|
||||
|
||||
|
||||
class ProxyHandler(http.server.BaseHTTPRequestHandler):
|
||||
# Silence default request logging (we log what matters ourselves)
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
def _read_body(self) -> dict:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
if length <= 0:
|
||||
return {}
|
||||
raw = self.rfile.read(length)
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _send_json(self, status: int, payload: dict) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_OPTIONS(self) -> None: # CORS preflight
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
if parsed.path == "/healthz":
|
||||
self._send_json(200, {
|
||||
"status": "ok",
|
||||
"atocore": ATOCORE_URL,
|
||||
"upstream": UPSTREAM_URL or "(not configured)",
|
||||
"inject": INJECT_ENABLED,
|
||||
"capture": CAPTURE_ENABLED,
|
||||
})
|
||||
return
|
||||
# Pass through GET to upstream (model listing etc)
|
||||
if not UPSTREAM_URL:
|
||||
self._send_json(503, {"error": {"message": "ATOCORE_UPSTREAM not configured"}})
|
||||
return
|
||||
try:
|
||||
req = urllib.request.Request(UPSTREAM_URL + parsed.path + (f"?{parsed.query}" if parsed.query else ""))
|
||||
for k in ("Authorization", "X-API-Key"):
|
||||
v = self.headers.get(k)
|
||||
if v:
|
||||
req.add_header(k, v)
|
||||
with urllib.request.urlopen(req, timeout=UPSTREAM_TIMEOUT) as resp:
|
||||
data = resp.read()
|
||||
self.send_response(resp.status)
|
||||
self.send_header("Content-Type", resp.headers.get("Content-Type", "application/json"))
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
except Exception as e:
|
||||
self._send_json(502, {"error": {"message": f"upstream error: {e}"}})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
body = self._read_body()
|
||||
|
||||
# Only enrich chat completions; other endpoints pass through
|
||||
if parsed.path.endswith("/chat/completions") or parsed.path == "/v1/chat/completions":
|
||||
prompt = get_last_user_message(body)
|
||||
project = detect_project(prompt)
|
||||
context = fetch_context(prompt, project) if prompt else ""
|
||||
if context:
|
||||
log(f"inject: project={project or '(none)'} chars={len(context)}")
|
||||
body = inject_context(body, context)
|
||||
|
||||
status, response = forward_to_upstream(body, dict(self.headers), parsed.path)
|
||||
self._send_json(status, response)
|
||||
|
||||
if status == 200:
|
||||
assistant_text = get_assistant_text(response)
|
||||
capture_interaction(prompt, assistant_text, project)
|
||||
else:
|
||||
# Non-chat endpoints (embeddings, completions, etc.) — pure passthrough
|
||||
status, response = forward_to_upstream(body, dict(self.headers), parsed.path)
|
||||
self._send_json(status, response)
|
||||
|
||||
|
||||
class ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not UPSTREAM_URL:
|
||||
log("WARNING: ATOCORE_UPSTREAM not set. Chat completions will fail.")
|
||||
log("Example: ATOCORE_UPSTREAM=http://localhost:11434/v1 for Ollama")
|
||||
server = ThreadedServer((PROXY_HOST, PROXY_PORT), ProxyHandler)
|
||||
log(f"listening on {PROXY_HOST}:{PROXY_PORT}")
|
||||
log(f"AtoCore: {ATOCORE_URL} inject={INJECT_ENABLED} capture={CAPTURE_ENABLED}")
|
||||
log(f"Upstream: {UPSTREAM_URL or '(not configured)'}")
|
||||
log(f"Client label: {CLIENT_LABEL}")
|
||||
log("Ready. Point your OpenAI-compatible client at /v1/chat/completions")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
log("stopping")
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
79
scripts/auto_promote_reinforced.py
Normal file
79
scripts/auto_promote_reinforced.py
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-promote reinforced candidates + expire stale ones.
|
||||
|
||||
Phase 10: reinforcement-based auto-promotion. Candidates referenced
|
||||
by 3+ interactions with confidence >= 0.7 graduate to active.
|
||||
Candidates unreinforced for 14+ days are auto-rejected.
|
||||
|
||||
Usage:
|
||||
python3 scripts/auto_promote_reinforced.py [--base-url URL] [--dry-run]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Allow importing from src/ when run from repo root
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from atocore.memory.service import auto_promote_reinforced, expire_stale_candidates
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Auto-promote + expire candidates")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Report only, don't change anything")
|
||||
parser.add_argument("--min-refs", type=int, default=3, help="Min reference_count for promotion")
|
||||
parser.add_argument("--min-confidence", type=float, default=0.7, help="Min confidence for promotion")
|
||||
parser.add_argument("--expire-days", type=int, default=14, help="Days before unreinforced candidates expire")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.dry_run:
|
||||
print("DRY RUN — no changes will be made")
|
||||
# For dry-run, query directly and report
|
||||
from atocore.models.database import get_connection
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
cutoff_promote = (datetime.now(timezone.utc) - timedelta(days=args.expire_days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
cutoff_expire = cutoff_promote
|
||||
|
||||
with get_connection() as conn:
|
||||
promotable = conn.execute(
|
||||
"SELECT id, content, memory_type, project, confidence, reference_count "
|
||||
"FROM memories WHERE status = 'candidate' "
|
||||
"AND COALESCE(reference_count, 0) >= ? AND confidence >= ? "
|
||||
"AND last_referenced_at >= ?",
|
||||
(args.min_refs, args.min_confidence, cutoff_promote),
|
||||
).fetchall()
|
||||
expirable = conn.execute(
|
||||
"SELECT id, content, memory_type, project "
|
||||
"FROM memories WHERE status = 'candidate' "
|
||||
"AND COALESCE(reference_count, 0) = 0 AND created_at < ?",
|
||||
(cutoff_expire,),
|
||||
).fetchall()
|
||||
|
||||
print(f"\nWould promote {len(promotable)} candidates:")
|
||||
for r in promotable:
|
||||
print(f" [{r['memory_type']}] refs={r['reference_count']} conf={r['confidence']:.2f} | {r['content'][:80]}...")
|
||||
print(f"\nWould expire {len(expirable)} stale candidates:")
|
||||
for r in expirable:
|
||||
print(f" [{r['memory_type']}] {r['project'] or 'global'} | {r['content'][:80]}...")
|
||||
return
|
||||
|
||||
promoted = auto_promote_reinforced(
|
||||
min_reference_count=args.min_refs,
|
||||
min_confidence=args.min_confidence,
|
||||
)
|
||||
expired = expire_stale_candidates(max_age_days=args.expire_days)
|
||||
|
||||
print(f"promoted={len(promoted)} expired={len(expired)}")
|
||||
if promoted:
|
||||
print(f"Promoted IDs: {promoted}")
|
||||
if expired:
|
||||
print(f"Expired IDs: {expired}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
551
scripts/auto_triage.py
Normal file
551
scripts/auto_triage.py
Normal file
@@ -0,0 +1,551 @@
|
||||
"""Auto-triage: LLM second-pass over candidate memories.
|
||||
|
||||
Fetches all status=candidate memories from the AtoCore API, asks
|
||||
a triage model (via claude -p) to classify each as promote / reject /
|
||||
needs_human, and executes the verdict via the promote/reject endpoints.
|
||||
Only needs_human candidates remain in the queue for manual review.
|
||||
|
||||
Trust model:
|
||||
- Auto-promote: model says promote AND confidence >= 0.8 AND no
|
||||
duplicate content in existing active memories
|
||||
- Auto-reject: model says reject
|
||||
- needs_human: everything else stays in queue
|
||||
|
||||
Runs host-side (same as batch extraction) because it needs the
|
||||
claude CLI. Intended to be called after batch-extract.sh in the
|
||||
nightly cron, or manually.
|
||||
|
||||
Usage:
|
||||
|
||||
python3 scripts/auto_triage.py --base-url http://localhost:8100
|
||||
python3 scripts/auto_triage.py --dry-run # preview without executing
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://localhost:8100")
|
||||
|
||||
# 3-tier escalation config (Phase "Triage Quality")
|
||||
TIER1_MODEL = os.environ.get("ATOCORE_TRIAGE_MODEL_TIER1",
|
||||
os.environ.get("ATOCORE_TRIAGE_MODEL", "sonnet"))
|
||||
TIER2_MODEL = os.environ.get("ATOCORE_TRIAGE_MODEL_TIER2", "opus")
|
||||
# Tier 3: default "discard" (auto-reject uncertain after opus disagrees/wavers),
|
||||
# alternative "human" routes them to /admin/triage.
|
||||
TIER3_ACTION = os.environ.get("ATOCORE_TRIAGE_TIER3", "discard").lower()
|
||||
DEFAULT_TIMEOUT_S = float(os.environ.get("ATOCORE_TRIAGE_TIMEOUT_S", "60"))
|
||||
TIER2_TIMEOUT_S = float(os.environ.get("ATOCORE_TRIAGE_TIER2_TIMEOUT_S", "120"))
|
||||
AUTO_PROMOTE_MIN_CONFIDENCE = 0.8
|
||||
# Below this, tier 1 decision is "not confident enough" and we escalate
|
||||
ESCALATION_CONFIDENCE_THRESHOLD = float(
|
||||
os.environ.get("ATOCORE_TRIAGE_ESCALATION_THRESHOLD", "0.75")
|
||||
)
|
||||
|
||||
# Kept for legacy callers that reference DEFAULT_MODEL
|
||||
DEFAULT_MODEL = TIER1_MODEL
|
||||
|
||||
TRIAGE_SYSTEM_PROMPT = """You are a memory triage reviewer for a personal context engine called AtoCore. You review candidate memories extracted from LLM conversations and decide whether each should be promoted to active status, rejected, or flagged for human review.
|
||||
|
||||
You will receive:
|
||||
- The candidate memory content, type, and claimed project
|
||||
- A list of existing active memories for the same project (to check for duplicates + contradictions)
|
||||
- Trusted project state entries (curated ground truth — higher trust than memories)
|
||||
- Known project ids so you can flag misattribution
|
||||
|
||||
For each candidate, output exactly one JSON object:
|
||||
|
||||
{"verdict": "promote|reject|needs_human|contradicts", "confidence": 0.0-1.0, "reason": "one sentence", "conflicts_with": "id of existing memory if contradicts", "domain_tags": ["tag1","tag2"], "valid_until": null}
|
||||
|
||||
DOMAIN TAGS (Phase 3): A lowercase list of 2-5 topical keywords describing
|
||||
the SUBJECT matter (not the project). This enables cross-project retrieval:
|
||||
a query about "optics" can pull matches from p04 + p05 + p06.
|
||||
|
||||
Good tags are single lowercase words or hyphenated terms. Mix:
|
||||
- domain keywords (optics, thermal, firmware, materials, controls)
|
||||
- project tokens when clearly scoped (p04, p05, p06, abb)
|
||||
- lifecycle/activity words (procurement, design, validation, vendor)
|
||||
|
||||
Always emit domain_tags on a promote. For reject, empty list is fine.
|
||||
|
||||
VALID_UNTIL (Phase 3): ISO date "YYYY-MM-DD" OR null (permanent).
|
||||
Set to a near-future date when the candidate is time-bounded:
|
||||
- Status snapshots ("current blocker is X") → ~2 weeks out
|
||||
- Scheduled events ("meeting Friday") → event date
|
||||
- Quotes with expiry → quote expiry date
|
||||
Leave null for durable decisions, engineering insights, ratified requirements.
|
||||
|
||||
Rules:
|
||||
|
||||
1. PROMOTE when the candidate states a durable architectural fact, ratified decision, standing rule, or engineering constraint that is NOT already covered by an existing active memory. Confidence should reflect how certain you are this is worth keeping.
|
||||
|
||||
2. REJECT when the candidate is:
|
||||
- A stale point-in-time snapshot ("live SHA is X", "36 active memories")
|
||||
- An implementation detail too granular to be useful as standalone context
|
||||
- A planned-but-not-implemented feature description
|
||||
- A duplicate or near-duplicate of an existing active memory
|
||||
- A session observation or conversational filler
|
||||
- A process rule that belongs in DEV-LEDGER.md or AGENTS.md, not memory
|
||||
|
||||
3. CONTRADICTS when the candidate *conflicts* with an existing active memory (not a duplicate, but states something that can't both be true). Set `conflicts_with` to the existing memory id. This flags the tension for human review instead of silently rejecting or double-storing. Examples: "Option A selected" vs "Option B selected" for the same decision; "uses material X" vs "uses material Y" for the same component.
|
||||
|
||||
4. OPENCLAW-CURATED content (candidate content starts with "From OpenClaw/"): apply a MUCH LOWER bar. OpenClaw's SOUL.md, USER.md, MEMORY.md, MODEL-ROUTING.md, and dated memory/*.md files are ALREADY curated by OpenClaw as canonical continuity. Promote unless clearly wrong or a genuine duplicate. Do NOT reject OpenClaw content as "process rule belongs elsewhere" or "session log" — that's exactly what AtoCore wants to absorb. Session events, project updates, stakeholder notes, and decisions from OpenClaw daily memory files ARE valuable context and should promote.
|
||||
|
||||
5. NEEDS_HUMAN when you're genuinely unsure — the candidate might be valuable but you can't tell without domain knowledge. This should be rare (< 20% of candidates). If this is just noise/filler, prefer REJECT with low confidence.
|
||||
|
||||
6. PROJECT VALIDATION: The candidate has a "claimed project". You'll see the list of registered project ids. If the claimed project doesn't match any registered id AND the content clearly belongs to a registered project, include "suggested_project": "<correct_id>" in your output so the caller can auto-fix the attribution. If the content is genuinely cross-project or global, leave project empty (suggested_project=""). Misattribution is the #1 pollution source — flag it.
|
||||
|
||||
7. TEMPORAL SENSITIVITY: Be aggressive with valid_until for anything that reads like "current state", "right now", "this week", "as of". Stale facts pollute context. When in doubt, set a 2-4 week expiry rather than null.
|
||||
|
||||
8. CONFIDENCE GRADING:
|
||||
- 0.9+: crystal clear durable fact or clear noise
|
||||
- 0.75-0.9: confident but not cryptographic-certain
|
||||
- 0.6-0.75: borderline — will escalate to opus for second opinion
|
||||
- <0.6: genuinely ambiguous — needs human or will be discarded
|
||||
|
||||
9. Output ONLY the JSON object. No prose, no markdown, no explanation outside the reason field. Include optional "suggested_project" field when misattribution detected."""
|
||||
|
||||
|
||||
TIER2_SECOND_OPINION_PROMPT = TRIAGE_SYSTEM_PROMPT + """
|
||||
|
||||
ESCALATED REVIEW: You are seeing this candidate because the tier-1 (sonnet) reviewer could not decide confidently. You will be shown tier-1's verdict + reason as additional context. Your job is to resolve the uncertainty with more careful thinking. Use your full context window to cross-reference the existing memories. If you ALSO cannot decide with confidence >= 0.8, output verdict="needs_human" with a clear explanation of what information would break the tie. That signal will route to a human (or auto-discard, depending on config)."""
|
||||
|
||||
_sandbox_cwd = None
|
||||
|
||||
|
||||
def get_sandbox_cwd():
|
||||
global _sandbox_cwd
|
||||
if _sandbox_cwd is None:
|
||||
_sandbox_cwd = tempfile.mkdtemp(prefix="ato-triage-")
|
||||
return _sandbox_cwd
|
||||
|
||||
|
||||
def api_get(base_url, path, timeout=10):
|
||||
req = urllib.request.Request(f"{base_url}{path}")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def api_post(base_url, path, body=None, timeout=10):
|
||||
data = json.dumps(body or {}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def fetch_active_memories_for_project(base_url, project):
|
||||
"""Fetch active memories for dedup checking."""
|
||||
params = "active_only=true&limit=50"
|
||||
if project:
|
||||
params += f"&project={urllib.parse.quote(project)}"
|
||||
result = api_get(base_url, f"/memory?{params}")
|
||||
return result.get("memories", [])
|
||||
|
||||
|
||||
def fetch_project_state(base_url, project):
|
||||
"""Fetch trusted project state for ground-truth context."""
|
||||
if not project:
|
||||
return []
|
||||
try:
|
||||
result = api_get(base_url, f"/project/state/{urllib.parse.quote(project)}")
|
||||
return result.get("entries", result.get("state", []))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def fetch_registered_projects(base_url):
|
||||
"""Return list of registered project ids + aliases for misattribution check."""
|
||||
try:
|
||||
result = api_get(base_url, "/projects")
|
||||
projects = result.get("projects", [])
|
||||
out = {}
|
||||
for p in projects:
|
||||
pid = p.get("project_id") or p.get("id") or p.get("name")
|
||||
if pid:
|
||||
out[pid] = p.get("aliases", []) or []
|
||||
return out
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def build_triage_user_message(candidate, active_memories, project_state, known_projects):
|
||||
"""Richer context for the triage model: memories + state + project registry."""
|
||||
active_summary = "\n".join(
|
||||
f"- [{m['memory_type']}] {m['content'][:200]}"
|
||||
for m in active_memories[:30]
|
||||
) or "(no active memories for this project)"
|
||||
|
||||
state_summary = ""
|
||||
if project_state:
|
||||
lines = []
|
||||
for e in project_state[:20]:
|
||||
cat = e.get("category", "?")
|
||||
key = e.get("key", "?")
|
||||
val = (e.get("value") or "")[:200]
|
||||
lines.append(f"- [{cat}/{key}] {val}")
|
||||
state_summary = "\n".join(lines)
|
||||
else:
|
||||
state_summary = "(no trusted state entries for this project)"
|
||||
|
||||
projects_line = ", ".join(sorted(known_projects.keys())) if known_projects else "(none)"
|
||||
|
||||
return (
|
||||
f"CANDIDATE TO TRIAGE:\n"
|
||||
f" type: {candidate['memory_type']}\n"
|
||||
f" claimed project: {candidate.get('project') or '(none)'}\n"
|
||||
f" content: {candidate['content']}\n\n"
|
||||
f"REGISTERED PROJECT IDS: {projects_line}\n\n"
|
||||
f"TRUSTED PROJECT STATE (ground truth, higher trust than memories):\n{state_summary}\n\n"
|
||||
f"EXISTING ACTIVE MEMORIES FOR THIS PROJECT:\n{active_summary}\n\n"
|
||||
f"Return the JSON verdict now."
|
||||
)
|
||||
|
||||
|
||||
def _call_claude(system_prompt, user_message, model, timeout_s):
|
||||
"""Shared CLI caller with retry + stderr capture."""
|
||||
args = [
|
||||
"claude", "-p",
|
||||
"--model", model,
|
||||
"--append-system-prompt", system_prompt,
|
||||
"--disable-slash-commands",
|
||||
user_message,
|
||||
]
|
||||
last_error = ""
|
||||
for attempt in range(3):
|
||||
if attempt > 0:
|
||||
time.sleep(2 ** attempt)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
args, capture_output=True, text=True,
|
||||
timeout=timeout_s, cwd=get_sandbox_cwd(),
|
||||
encoding="utf-8", errors="replace",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
last_error = f"{model} timed out"
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_error = f"subprocess error: {exc}"
|
||||
continue
|
||||
|
||||
if completed.returncode == 0:
|
||||
return (completed.stdout or "").strip(), None
|
||||
|
||||
stderr = (completed.stderr or "").strip()[:200]
|
||||
last_error = f"{model} exit {completed.returncode}: {stderr}" if stderr else f"{model} exit {completed.returncode}"
|
||||
return None, last_error
|
||||
|
||||
|
||||
def triage_one(candidate, active_memories, project_state, known_projects, model, timeout_s):
|
||||
"""Tier-1 triage: ask the cheap model for a verdict."""
|
||||
if not shutil.which("claude"):
|
||||
return {"verdict": "needs_human", "confidence": 0.0, "reason": "claude CLI not available"}
|
||||
|
||||
user_message = build_triage_user_message(candidate, active_memories, project_state, known_projects)
|
||||
raw, err = _call_claude(TRIAGE_SYSTEM_PROMPT, user_message, model, timeout_s)
|
||||
if err:
|
||||
return {"verdict": "needs_human", "confidence": 0.0, "reason": err}
|
||||
return parse_verdict(raw)
|
||||
|
||||
|
||||
def triage_escalation(candidate, tier1_verdict, active_memories, project_state, known_projects, model, timeout_s):
|
||||
"""Tier-2 escalation: opus sees tier-1's verdict + reasoning, tries again."""
|
||||
if not shutil.which("claude"):
|
||||
return {"verdict": "needs_human", "confidence": 0.0, "reason": "claude CLI not available"}
|
||||
|
||||
base_msg = build_triage_user_message(candidate, active_memories, project_state, known_projects)
|
||||
tier1_context = (
|
||||
f"\nTIER-1 REVIEW (sonnet, for your reference):\n"
|
||||
f" verdict: {tier1_verdict.get('verdict')}\n"
|
||||
f" confidence: {tier1_verdict.get('confidence', 0.0):.2f}\n"
|
||||
f" reason: {tier1_verdict.get('reason', '')[:300]}\n\n"
|
||||
f"Resolve the uncertainty. If you also can't decide with confidence ≥ 0.8, "
|
||||
f"return verdict='needs_human' with a specific explanation of what information "
|
||||
f"would break the tie.\n\nReturn the JSON verdict now."
|
||||
)
|
||||
raw, err = _call_claude(TIER2_SECOND_OPINION_PROMPT, base_msg + tier1_context, model, timeout_s)
|
||||
if err:
|
||||
return {"verdict": "needs_human", "confidence": 0.0, "reason": f"tier2: {err}"}
|
||||
return parse_verdict(raw)
|
||||
|
||||
|
||||
def parse_verdict(raw):
|
||||
"""Parse the triage model's JSON verdict."""
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
nl = text.find("\n")
|
||||
if nl >= 0:
|
||||
text = text[nl + 1:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
|
||||
if not text.lstrip().startswith("{"):
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
text = text[start:end + 1]
|
||||
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return {"verdict": "needs_human", "confidence": 0.0, "reason": "failed to parse triage output"}
|
||||
|
||||
verdict = str(parsed.get("verdict", "needs_human")).strip().lower()
|
||||
if verdict not in {"promote", "reject", "needs_human", "contradicts"}:
|
||||
verdict = "needs_human"
|
||||
|
||||
confidence = parsed.get("confidence", 0.5)
|
||||
try:
|
||||
confidence = max(0.0, min(1.0, float(confidence)))
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.5
|
||||
|
||||
reason = str(parsed.get("reason", "")).strip()[:200]
|
||||
conflicts_with = str(parsed.get("conflicts_with", "")).strip()
|
||||
|
||||
# Phase 3: domain tags + expiry
|
||||
raw_tags = parsed.get("domain_tags") or []
|
||||
if isinstance(raw_tags, str):
|
||||
raw_tags = [t.strip() for t in raw_tags.split(",") if t.strip()]
|
||||
if not isinstance(raw_tags, list):
|
||||
raw_tags = []
|
||||
domain_tags = []
|
||||
for t in raw_tags[:10]:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
tag = t.strip().lower()
|
||||
if tag and tag not in domain_tags:
|
||||
domain_tags.append(tag)
|
||||
|
||||
valid_until = parsed.get("valid_until")
|
||||
if valid_until is None:
|
||||
valid_until = ""
|
||||
else:
|
||||
valid_until = str(valid_until).strip()
|
||||
if valid_until.lower() in ("", "null", "none", "permanent"):
|
||||
valid_until = ""
|
||||
|
||||
# Triage Quality: project misattribution flag
|
||||
suggested_project = str(parsed.get("suggested_project", "")).strip()
|
||||
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"confidence": confidence,
|
||||
"reason": reason,
|
||||
"conflicts_with": conflicts_with,
|
||||
"domain_tags": domain_tags,
|
||||
"valid_until": valid_until,
|
||||
"suggested_project": suggested_project,
|
||||
}
|
||||
|
||||
|
||||
def _apply_metadata_update(base_url, mid, verdict_obj):
|
||||
"""Persist tags + valid_until + suggested_project before the promote call."""
|
||||
tags = verdict_obj.get("domain_tags") or []
|
||||
valid_until = verdict_obj.get("valid_until") or ""
|
||||
suggested = verdict_obj.get("suggested_project") or ""
|
||||
|
||||
body = {}
|
||||
if tags:
|
||||
body["domain_tags"] = tags
|
||||
if valid_until:
|
||||
body["valid_until"] = valid_until
|
||||
if not body and not suggested:
|
||||
return
|
||||
|
||||
if body:
|
||||
try:
|
||||
import urllib.request as _ur
|
||||
req = _ur.Request(
|
||||
f"{base_url}/memory/{mid}", method="PUT",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
)
|
||||
_ur.urlopen(req, timeout=10).read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Project auto-fix via direct SQLite update would bypass audit; use PUT if supported.
|
||||
# For now we log the suggestion — operator script can apply it in batch.
|
||||
if suggested:
|
||||
# noop here — handled by caller which tracks suggested_project_fixes
|
||||
pass
|
||||
|
||||
|
||||
def process_candidate(cand, base_url, active_cache, state_cache, known_projects, dry_run):
|
||||
"""Run the 3-tier triage and apply the resulting action.
|
||||
|
||||
Returns (action, note) where action in {promote, reject, discard, human, error}.
|
||||
"""
|
||||
mid = cand["id"]
|
||||
project = cand.get("project") or ""
|
||||
if project not in active_cache:
|
||||
active_cache[project] = fetch_active_memories_for_project(base_url, project)
|
||||
if project not in state_cache:
|
||||
state_cache[project] = fetch_project_state(base_url, project)
|
||||
|
||||
# === Tier 1 ===
|
||||
v1 = triage_one(
|
||||
cand, active_cache[project], state_cache[project],
|
||||
known_projects, TIER1_MODEL, DEFAULT_TIMEOUT_S,
|
||||
)
|
||||
|
||||
# Project misattribution fix: suggested_project surfaces from tier 1
|
||||
suggested = (v1.get("suggested_project") or "").strip()
|
||||
if suggested and suggested != project and suggested in known_projects:
|
||||
# Try to re-canonicalize the memory's project
|
||||
if not dry_run:
|
||||
try:
|
||||
import urllib.request as _ur
|
||||
req = _ur.Request(
|
||||
f"{base_url}/memory/{mid}", method="PUT",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps({"content": cand["content"]}).encode("utf-8"),
|
||||
)
|
||||
_ur.urlopen(req, timeout=10).read() # triggers canonicalization via update
|
||||
except Exception:
|
||||
pass
|
||||
print(f" ↺ misattribution flagged: {project!r} → {suggested!r}")
|
||||
|
||||
# High-confidence tier 1 decision → act
|
||||
if v1["verdict"] in ("promote", "reject") and v1["confidence"] >= AUTO_PROMOTE_MIN_CONFIDENCE:
|
||||
return _apply_verdict(v1, cand, base_url, active_cache, dry_run, tier="sonnet")
|
||||
|
||||
# Borderline or uncertain → escalate to tier 2 (opus)
|
||||
print(f" ↑ escalating (tier1 verdict={v1['verdict']} conf={v1['confidence']:.2f})")
|
||||
v2 = triage_escalation(
|
||||
cand, v1, active_cache[project], state_cache[project],
|
||||
known_projects, TIER2_MODEL, TIER2_TIMEOUT_S,
|
||||
)
|
||||
|
||||
# Tier 2 is confident → act
|
||||
if v2["verdict"] in ("promote", "reject") and v2["confidence"] >= AUTO_PROMOTE_MIN_CONFIDENCE:
|
||||
return _apply_verdict(v2, cand, base_url, active_cache, dry_run, tier="opus")
|
||||
|
||||
# Tier 3: still uncertain — route per config
|
||||
if TIER3_ACTION == "discard":
|
||||
reason = f"tier1+tier2 uncertain: {v2.get('reason', '')[:150]}"
|
||||
if dry_run:
|
||||
return ("discard", reason)
|
||||
try:
|
||||
api_post(base_url, f"/memory/{mid}/reject")
|
||||
except Exception:
|
||||
return ("error", reason)
|
||||
return ("discard", reason)
|
||||
else:
|
||||
# "human" — leave in queue for /admin/triage review
|
||||
return ("human", v2.get("reason", "no reason")[:200])
|
||||
|
||||
|
||||
def _apply_verdict(verdict_obj, cand, base_url, active_cache, dry_run, tier):
|
||||
"""Execute the promote/reject action and update metadata."""
|
||||
mid = cand["id"]
|
||||
verdict = verdict_obj["verdict"]
|
||||
conf = verdict_obj["confidence"]
|
||||
reason = f"[{tier}] {verdict_obj['reason']}"
|
||||
|
||||
if verdict == "promote":
|
||||
if dry_run:
|
||||
return ("promote", reason)
|
||||
_apply_metadata_update(base_url, mid, verdict_obj)
|
||||
try:
|
||||
api_post(base_url, f"/memory/{mid}/promote")
|
||||
project = cand.get("project") or ""
|
||||
if project in active_cache:
|
||||
active_cache[project].append(cand)
|
||||
return ("promote", reason)
|
||||
except Exception as e:
|
||||
return ("error", f"promote failed: {e}")
|
||||
else:
|
||||
if dry_run:
|
||||
return ("reject", reason)
|
||||
try:
|
||||
api_post(base_url, f"/memory/{mid}/reject")
|
||||
return ("reject", reason)
|
||||
except Exception as e:
|
||||
return ("error", f"reject failed: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Auto-triage candidate memories (3-tier escalation)")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--dry-run", action="store_true", help="preview without executing")
|
||||
parser.add_argument("--max-batches", type=int, default=20,
|
||||
help="Max batches of 100 to process per run")
|
||||
parser.add_argument("--no-escalation", action="store_true",
|
||||
help="Disable tier-2 escalation (legacy single-model behavior)")
|
||||
args = parser.parse_args()
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
active_cache: dict[str, list] = {}
|
||||
state_cache: dict[str, list] = {}
|
||||
|
||||
known_projects = fetch_registered_projects(args.base_url)
|
||||
print(f"Registered projects: {sorted(known_projects.keys())}")
|
||||
print(f"Tier1: {TIER1_MODEL} Tier2: {TIER2_MODEL} Tier3: {TIER3_ACTION} "
|
||||
f"escalation_threshold: {ESCALATION_CONFIDENCE_THRESHOLD}")
|
||||
|
||||
counts = {"promote": 0, "reject": 0, "discard": 0, "human": 0, "error": 0}
|
||||
batch_num = 0
|
||||
|
||||
while batch_num < args.max_batches:
|
||||
batch_num += 1
|
||||
result = api_get(args.base_url, "/memory?status=candidate&limit=100")
|
||||
all_candidates = result.get("memories", [])
|
||||
candidates = [c for c in all_candidates if c["id"] not in seen_ids]
|
||||
|
||||
if not candidates:
|
||||
if batch_num == 1:
|
||||
print("queue empty, nothing to triage")
|
||||
else:
|
||||
print(f"\nQueue drained after batch {batch_num-1}.")
|
||||
break
|
||||
|
||||
print(f"\n=== batch {batch_num}: {len(candidates)} candidates dry_run: {args.dry_run} ===")
|
||||
|
||||
for i, cand in enumerate(candidates, 1):
|
||||
if i > 1:
|
||||
time.sleep(0.5)
|
||||
seen_ids.add(cand["id"])
|
||||
mid = cand["id"]
|
||||
label = f"[{i:2d}/{len(candidates)}] {mid[:8]} [{cand['memory_type']}]"
|
||||
|
||||
try:
|
||||
action, note = process_candidate(
|
||||
cand, args.base_url, active_cache, state_cache,
|
||||
known_projects, args.dry_run,
|
||||
)
|
||||
except Exception as e:
|
||||
action, note = ("error", f"exception: {e}")
|
||||
|
||||
counts[action] = counts.get(action, 0) + 1
|
||||
verb = {"promote": "PROMOTED ", "reject": "REJECTED ",
|
||||
"discard": "DISCARDED ", "human": "NEEDS_HUM ",
|
||||
"error": "ERROR "}.get(action, action.upper())
|
||||
if args.dry_run and action in ("promote", "reject", "discard"):
|
||||
verb = "WOULD " + verb.strip()
|
||||
print(f" {verb} {label} {note[:120]}")
|
||||
|
||||
print(
|
||||
f"\ntotal: promoted={counts['promote']} rejected={counts['reject']} "
|
||||
f"discarded={counts['discard']} human={counts['human']} errors={counts['error']} "
|
||||
f"batches={batch_num}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
272
scripts/batch_llm_extract_live.py
Normal file
272
scripts/batch_llm_extract_live.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""Host-side LLM batch extraction — HTTP client + shared prompt module.
|
||||
|
||||
Fetches interactions from the AtoCore API, runs ``claude -p`` locally
|
||||
for each, and POSTs candidates back. Uses stdlib + the ``claude`` CLI
|
||||
on PATH, plus the stdlib-only shared prompt/parser module at
|
||||
``atocore.memory._llm_prompt`` to eliminate prompt/parser drift
|
||||
against the in-container extractor (R12).
|
||||
|
||||
This is necessary because the ``claude`` CLI is on the Dalidou HOST
|
||||
but not inside the Docker container, and the host's Python doesn't
|
||||
have the container's dependencies (pydantic_settings, etc.) — so we
|
||||
only import the one stdlib-only module, not the full atocore package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# R12: share the prompt + parser with the in-container extractor so
|
||||
# the two paths can't drift. The imported module is stdlib-only by
|
||||
# design; see src/atocore/memory/_llm_prompt.py.
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_SRC_DIR = os.path.abspath(os.path.join(_SCRIPT_DIR, "..", "src"))
|
||||
if _SRC_DIR not in sys.path:
|
||||
sys.path.insert(0, _SRC_DIR)
|
||||
|
||||
from atocore.memory._llm_prompt import ( # noqa: E402
|
||||
MEMORY_TYPES,
|
||||
SYSTEM_PROMPT,
|
||||
build_user_message,
|
||||
normalize_candidate_item,
|
||||
parse_llm_json_array,
|
||||
)
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://localhost:8100")
|
||||
DEFAULT_MODEL = os.environ.get("ATOCORE_LLM_EXTRACTOR_MODEL", "sonnet")
|
||||
DEFAULT_TIMEOUT_S = float(os.environ.get("ATOCORE_LLM_EXTRACTOR_TIMEOUT_S", "90"))
|
||||
|
||||
|
||||
_sandbox_cwd = None
|
||||
|
||||
|
||||
def get_sandbox_cwd():
|
||||
global _sandbox_cwd
|
||||
if _sandbox_cwd is None:
|
||||
_sandbox_cwd = tempfile.mkdtemp(prefix="ato-llm-extract-")
|
||||
return _sandbox_cwd
|
||||
|
||||
|
||||
def api_get(base_url, path, timeout=10):
|
||||
req = urllib.request.Request(f"{base_url}{path}")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def api_post(base_url, path, body, timeout=10):
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def get_last_run(base_url):
|
||||
try:
|
||||
state = api_get(base_url, "/project/state/atocore?category=status")
|
||||
for entry in state.get("entries", []):
|
||||
if entry.get("key") == "last_extract_batch_run":
|
||||
return entry["value"]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def set_last_run(base_url, timestamp):
|
||||
try:
|
||||
api_post(base_url, "/project/state", {
|
||||
"project": "atocore", "category": "status",
|
||||
"key": "last_extract_batch_run", "value": timestamp,
|
||||
"source": "batch_llm_extract_live.py",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_known_projects: set[str] = set()
|
||||
|
||||
|
||||
def _load_known_projects(base_url):
|
||||
"""Fetch registered project IDs from the API for R9 validation."""
|
||||
global _known_projects
|
||||
try:
|
||||
data = api_get(base_url, "/projects")
|
||||
_known_projects = {p["id"] for p in data.get("projects", [])}
|
||||
for p in data.get("projects", []):
|
||||
for alias in p.get("aliases", []):
|
||||
_known_projects.add(alias)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def extract_one(prompt, response, project, model, timeout_s):
|
||||
"""Run claude -p on one interaction, return parsed candidates."""
|
||||
if not shutil.which("claude"):
|
||||
return [], "claude_cli_missing"
|
||||
|
||||
user_message = build_user_message(prompt, response, project)
|
||||
|
||||
args = [
|
||||
"claude", "-p",
|
||||
"--model", model,
|
||||
"--append-system-prompt", SYSTEM_PROMPT,
|
||||
"--disable-slash-commands",
|
||||
user_message,
|
||||
]
|
||||
|
||||
# Retry with exponential backoff on transient failures (rate limits etc)
|
||||
import time as _time
|
||||
last_error = ""
|
||||
for attempt in range(3):
|
||||
if attempt > 0:
|
||||
_time.sleep(2 ** attempt) # 2s, 4s
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
args, capture_output=True, text=True,
|
||||
timeout=timeout_s, cwd=get_sandbox_cwd(),
|
||||
encoding="utf-8", errors="replace",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
last_error = "timeout"
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_error = f"subprocess_error: {exc}"
|
||||
continue
|
||||
|
||||
if completed.returncode == 0:
|
||||
raw = (completed.stdout or "").strip()
|
||||
return parse_candidates(raw, project), ""
|
||||
|
||||
# Capture stderr for diagnostics (truncate to 200 chars)
|
||||
stderr = (completed.stderr or "").strip()[:200]
|
||||
last_error = f"exit_{completed.returncode}: {stderr}" if stderr else f"exit_{completed.returncode}"
|
||||
|
||||
return [], last_error
|
||||
|
||||
|
||||
def parse_candidates(raw, interaction_project):
|
||||
"""Parse model JSON output into candidate dicts.
|
||||
|
||||
Stripping + per-item normalization come from the shared
|
||||
``_llm_prompt`` module. Host-side project attribution: interaction
|
||||
scope wins, otherwise keep the model's tag (the API's own R9
|
||||
registry-check will happen server-side in the container on write;
|
||||
here we preserve the signal instead of dropping it).
|
||||
"""
|
||||
results = []
|
||||
for item in parse_llm_json_array(raw):
|
||||
normalized = normalize_candidate_item(item)
|
||||
if normalized is None:
|
||||
continue
|
||||
project = interaction_project or normalized["project"] or ""
|
||||
results.append({
|
||||
"memory_type": normalized["type"],
|
||||
"content": normalized["content"],
|
||||
"project": project,
|
||||
"confidence": normalized["confidence"],
|
||||
"domain_tags": normalized.get("domain_tags") or [],
|
||||
"valid_until": normalized.get("valid_until") or "",
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Host-side LLM batch extraction")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--limit", type=int, default=50)
|
||||
parser.add_argument("--since", default=None)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL)
|
||||
args = parser.parse_args()
|
||||
|
||||
_load_known_projects(args.base_url)
|
||||
since = args.since or get_last_run(args.base_url)
|
||||
print(f"since={since or '(first run)'} limit={args.limit} model={args.model} known_projects={len(_known_projects)}")
|
||||
|
||||
params = [f"limit={args.limit}"]
|
||||
if since:
|
||||
params.append(f"since={urllib.parse.quote(since)}")
|
||||
listing = api_get(args.base_url, f"/interactions?{'&'.join(params)}")
|
||||
interaction_summaries = listing.get("interactions", [])
|
||||
print(f"listed {len(interaction_summaries)} interactions")
|
||||
|
||||
processed = 0
|
||||
total_candidates = 0
|
||||
total_persisted = 0
|
||||
errors = 0
|
||||
|
||||
import time as _time
|
||||
for ix, summary in enumerate(interaction_summaries):
|
||||
resp_chars = summary.get("response_chars", 0) or 0
|
||||
if resp_chars < 50:
|
||||
continue
|
||||
# Light pacing between calls to avoid bursting the claude CLI
|
||||
if ix > 0:
|
||||
_time.sleep(0.5)
|
||||
iid = summary["id"]
|
||||
try:
|
||||
raw = api_get(
|
||||
args.base_url,
|
||||
f"/interactions/{urllib.parse.quote(iid, safe='')}",
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f" ! {iid[:8]}: fetch failed: {exc}", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
response_text = raw.get("response", "") or ""
|
||||
if not response_text.strip() or len(response_text) < 50:
|
||||
continue
|
||||
|
||||
candidates, error = extract_one(
|
||||
prompt=raw.get("prompt", "") or "",
|
||||
response=response_text,
|
||||
project=raw.get("project", "") or "",
|
||||
model=args.model,
|
||||
timeout_s=DEFAULT_TIMEOUT_S,
|
||||
)
|
||||
|
||||
if error:
|
||||
print(f" ! {raw['id'][:8]}: {error}", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
processed += 1
|
||||
total_candidates += len(candidates)
|
||||
|
||||
for c in candidates:
|
||||
try:
|
||||
api_post(args.base_url, "/memory", {
|
||||
"memory_type": c["memory_type"],
|
||||
"content": c["content"],
|
||||
"project": c["project"],
|
||||
"confidence": c["confidence"],
|
||||
"status": "candidate",
|
||||
"domain_tags": c.get("domain_tags") or [],
|
||||
"valid_until": c.get("valid_until") or "",
|
||||
})
|
||||
total_persisted += 1
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code != 400:
|
||||
errors += 1
|
||||
except Exception:
|
||||
errors += 1
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
set_last_run(args.base_url, now)
|
||||
|
||||
print(f"processed={processed} candidates={total_candidates} persisted={total_persisted} errors={errors}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
188
scripts/bootstrap_entities.py
Normal file
188
scripts/bootstrap_entities.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Bootstrap engineering entities from existing project knowledge.
|
||||
|
||||
One-shot script that seeds the entity/relationship graph from what
|
||||
AtoCore already knows via memories, project state, and vault docs.
|
||||
Safe to re-run — uses name+project dedup.
|
||||
|
||||
Usage:
|
||||
|
||||
python3 scripts/bootstrap_entities.py --base-url http://localhost:8100
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://dalidou:8100")
|
||||
|
||||
|
||||
def post(base_url, path, body):
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def entity(base_url, etype, name, project="", desc="", props=None):
|
||||
result = post(base_url, "/entities", {
|
||||
"entity_type": etype, "name": name, "project": project,
|
||||
"description": desc, "properties": props or {},
|
||||
})
|
||||
eid = result.get("id", "")
|
||||
status = "+" if eid else "skip"
|
||||
print(f" {status} [{etype}] {name}")
|
||||
return eid
|
||||
|
||||
|
||||
def rel(base_url, src, tgt, rtype):
|
||||
if not src or not tgt:
|
||||
return
|
||||
result = post(base_url, "/relationships", {
|
||||
"source_entity_id": src, "target_entity_id": tgt,
|
||||
"relationship_type": rtype,
|
||||
})
|
||||
print(f" -> {rtype}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
args = parser.parse_args()
|
||||
b = args.base_url
|
||||
|
||||
print("=== P04 GigaBIT M1 ===")
|
||||
p04 = entity(b, "project", "GigaBIT M1", "p04-gigabit",
|
||||
"1.2m primary mirror for stratospheric balloon telescope")
|
||||
|
||||
p04_m1 = entity(b, "system", "M1 Mirror Assembly", "p04-gigabit",
|
||||
"Primary mirror blank + support system + reference frame")
|
||||
rel(b, p04, p04_m1, "contains")
|
||||
|
||||
p04_vs = entity(b, "subsystem", "Vertical Support", "p04-gigabit",
|
||||
"18-point whiffletree axial support from below")
|
||||
p04_ls = entity(b, "subsystem", "Lateral Support", "p04-gigabit",
|
||||
"Circumferential constraint system with GF-PTFE pads")
|
||||
p04_rf = entity(b, "subsystem", "Reference Frame", "p04-gigabit",
|
||||
"Structural mounting interface between mirror and OTA")
|
||||
p04_blank = entity(b, "component", "M1 Blank", "p04-gigabit",
|
||||
"1.2m Zerodur aspheric blank from Schott",
|
||||
{"material": "Zerodur", "diameter_m": 1.2, "focal_ratio": "F/1.2"})
|
||||
rel(b, p04_m1, p04_vs, "contains")
|
||||
rel(b, p04_m1, p04_ls, "contains")
|
||||
rel(b, p04_m1, p04_rf, "contains")
|
||||
rel(b, p04_m1, p04_blank, "contains")
|
||||
|
||||
p04_zerodur = entity(b, "material", "Zerodur", "p04-gigabit",
|
||||
"Glass-ceramic with near-zero CTE for mirror blanks")
|
||||
p04_ptfe = entity(b, "material", "GF-PTFE", "p04-gigabit",
|
||||
"Glass-filled PTFE for thermal stability on lateral pads")
|
||||
rel(b, p04_blank, p04_zerodur, "uses_material")
|
||||
rel(b, p04_ls, p04_ptfe, "uses_material")
|
||||
|
||||
p04_optb = entity(b, "decision", "Option B Conical Back", "p04-gigabit",
|
||||
"Selected mirror architecture: conical-back lightweighting")
|
||||
rel(b, p04_optb, p04_blank, "affected_by_decision")
|
||||
|
||||
p04_wfe = entity(b, "requirement", "WFE < 15nm RMS filtered", "p04-gigabit",
|
||||
"Filtered mechanical wavefront error below 15 nm across 20-60 deg elevation")
|
||||
p04_mass = entity(b, "requirement", "Mass < 103.5 kg", "p04-gigabit",
|
||||
"Total mirror assembly mass constraint")
|
||||
rel(b, p04_m1, p04_wfe, "constrained_by")
|
||||
rel(b, p04_m1, p04_mass, "constrained_by")
|
||||
|
||||
print("\n=== P05 Interferometer ===")
|
||||
p05 = entity(b, "project", "Interferometer System", "p05-interferometer",
|
||||
"Metrology system for GigaBIT M1 figuring")
|
||||
|
||||
p05_rig = entity(b, "system", "Test Rig", "p05-interferometer",
|
||||
"Folded-beam interferometric test setup for M1 measurement")
|
||||
rel(b, p05, p05_rig, "contains")
|
||||
|
||||
p05_ifm = entity(b, "component", "Interferometer", "p05-interferometer",
|
||||
"Fixed horizontal Twyman-Green dynamic interferometer")
|
||||
p05_fold = entity(b, "component", "Fold Mirror", "p05-interferometer",
|
||||
"45-degree beam redirect, <= lambda/20 surface quality")
|
||||
p05_cgh = entity(b, "component", "CGH Null Corrector", "p05-interferometer",
|
||||
"6-inch transmission CGH for F/1.2 asphere null test",
|
||||
{"diameter": "6 inch", "substrate": "fused silica", "error_budget_nm": 5.5})
|
||||
p05_tilt = entity(b, "subsystem", "Tilting Platform", "p05-interferometer",
|
||||
"Mirror tilting platform, co-tilts with interferometer")
|
||||
rel(b, p05_rig, p05_ifm, "contains")
|
||||
rel(b, p05_rig, p05_fold, "contains")
|
||||
rel(b, p05_rig, p05_cgh, "contains")
|
||||
rel(b, p05_rig, p05_tilt, "contains")
|
||||
rel(b, p05_ifm, p05_fold, "interfaces_with")
|
||||
rel(b, p05_cgh, p05_tilt, "interfaces_with")
|
||||
|
||||
p05_vendor_dec = entity(b, "decision", "Vendor Path: Twyman-Green preferred", "p05-interferometer",
|
||||
"4D technical lead but cost-challenged; Zygo Verifire SV at 55K is value path")
|
||||
p05_vendor_zygo = entity(b, "vendor", "Zygo / AMETEK", "p05-interferometer",
|
||||
"Certified used Verifire SV, 55K, Nabeel Sufi contact")
|
||||
p05_vendor_4d = entity(b, "vendor", "4D Technology", "p05-interferometer",
|
||||
"PC6110/PC4030, above budget but strongest technical option")
|
||||
p05_vendor_aom = entity(b, "vendor", "AOM (CGH)", "p05-interferometer",
|
||||
"CGH design and fabrication, 28-30K package")
|
||||
rel(b, p05_vendor_dec, p05_ifm, "affected_by_decision")
|
||||
|
||||
print("\n=== P06 Polisher ===")
|
||||
p06 = entity(b, "project", "Polisher System", "p06-polisher",
|
||||
"Machine overhaul + software suite for optical polishing")
|
||||
|
||||
p06_machine = entity(b, "system", "Polisher Machine", "p06-polisher",
|
||||
"Swing-arm polishing machine with force modulation")
|
||||
p06_sw = entity(b, "system", "Software Suite", "p06-polisher",
|
||||
"Three-layer software: polisher-sim, polisher-post, polisher-control")
|
||||
rel(b, p06, p06_machine, "contains")
|
||||
rel(b, p06, p06_sw, "contains")
|
||||
|
||||
p06_sim = entity(b, "subsystem", "polisher-sim", "p06-polisher",
|
||||
"Digital twin: surface assimilation, removal simulation, planning")
|
||||
p06_post = entity(b, "subsystem", "polisher-post", "p06-polisher",
|
||||
"Bridge: validation, translation, packaging for machine")
|
||||
p06_ctrl = entity(b, "subsystem", "polisher-control", "p06-polisher",
|
||||
"Executor: state machine, interlocks, telemetry, run logs")
|
||||
rel(b, p06_sw, p06_sim, "contains")
|
||||
rel(b, p06_sw, p06_post, "contains")
|
||||
rel(b, p06_sw, p06_ctrl, "contains")
|
||||
rel(b, p06_sim, p06_post, "interfaces_with")
|
||||
rel(b, p06_post, p06_ctrl, "interfaces_with")
|
||||
|
||||
p06_fc = entity(b, "subsystem", "Force Control", "p06-polisher",
|
||||
"Frame-grounded counterweight actuator with cable tension modulation",
|
||||
{"actuator_capacity_N": "150-200", "compliance_spring_Nmm": "3-5"})
|
||||
p06_zaxis = entity(b, "component", "Z-Axis", "p06-polisher",
|
||||
"Binary engage/retract mechanism, not continuous position")
|
||||
p06_cam = entity(b, "component", "Cam Mechanism", "p06-polisher",
|
||||
"Mechanically set by operator, read by encoders, not actuated")
|
||||
rel(b, p06_machine, p06_fc, "contains")
|
||||
rel(b, p06_machine, p06_zaxis, "contains")
|
||||
rel(b, p06_machine, p06_cam, "contains")
|
||||
|
||||
p06_fw = entity(b, "decision", "Firmware Interface Contract", "p06-polisher",
|
||||
"controller-job.v1 in, run-log.v1 + telemetry out — invariant")
|
||||
p06_offline = entity(b, "decision", "Offline-First Design", "p06-polisher",
|
||||
"Machine works fully offline; network is for remote access only")
|
||||
p06_usb = entity(b, "decision", "USB SSD Storage", "p06-polisher",
|
||||
"USB SSD mandatory on RPi, not SD card")
|
||||
|
||||
p06_contracts = entity(b, "constraint", "Shared Contracts", "p06-polisher",
|
||||
"Stable IDs, explicit versions, hashable artifacts, planned-vs-executed separation")
|
||||
rel(b, p06_sw, p06_contracts, "constrained_by")
|
||||
|
||||
p06_preston = entity(b, "parameter", "Preston Coefficient kp", "p06-polisher",
|
||||
"Calibrated from before/after surface measurements, multi-run inverse-variance weighting")
|
||||
|
||||
print(f"\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
254
scripts/canonicalize_tags.py
Normal file
254
scripts/canonicalize_tags.py
Normal file
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase 7C — tag canonicalization detector.
|
||||
|
||||
Weekly (or on-demand) LLM pass that:
|
||||
1. Fetches the tag distribution across all active memories via HTTP
|
||||
2. Asks claude-p to propose alias→canonical mappings
|
||||
3. AUTO-APPLIES aliases with confidence >= AUTO_APPROVE_CONF (0.8)
|
||||
4. Submits lower-confidence proposals as pending for human review
|
||||
|
||||
Autonomous by default — matches the Phase 7A.1 pattern. Set
|
||||
--no-auto-approve to force every proposal into human review.
|
||||
|
||||
Host-side because claude CLI lives on Dalidou, not the container.
|
||||
Reuses the PYTHONPATH=src pattern from scripts/memory_dedup.py.
|
||||
|
||||
Usage:
|
||||
python3 scripts/canonicalize_tags.py [--base-url URL] [--dry-run] [--no-auto-approve]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_SRC_DIR = os.path.abspath(os.path.join(_SCRIPT_DIR, "..", "src"))
|
||||
if _SRC_DIR not in sys.path:
|
||||
sys.path.insert(0, _SRC_DIR)
|
||||
|
||||
from atocore.memory._tag_canon_prompt import ( # noqa: E402
|
||||
PROTECTED_PROJECT_TOKENS,
|
||||
SYSTEM_PROMPT,
|
||||
TAG_CANON_PROMPT_VERSION,
|
||||
build_user_message,
|
||||
normalize_alias_item,
|
||||
parse_canon_output,
|
||||
)
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://127.0.0.1:8100")
|
||||
DEFAULT_MODEL = os.environ.get("ATOCORE_TAG_CANON_MODEL", "sonnet")
|
||||
DEFAULT_TIMEOUT_S = float(os.environ.get("ATOCORE_TAG_CANON_TIMEOUT_S", "90"))
|
||||
|
||||
AUTO_APPROVE_CONF = float(os.environ.get("ATOCORE_TAG_CANON_AUTO_APPROVE_CONF", "0.8"))
|
||||
MIN_ALIAS_COUNT = int(os.environ.get("ATOCORE_TAG_CANON_MIN_ALIAS_COUNT", "1"))
|
||||
|
||||
_sandbox_cwd = None
|
||||
|
||||
|
||||
def get_sandbox_cwd() -> str:
|
||||
global _sandbox_cwd
|
||||
if _sandbox_cwd is None:
|
||||
_sandbox_cwd = tempfile.mkdtemp(prefix="ato-tagcanon-")
|
||||
return _sandbox_cwd
|
||||
|
||||
|
||||
def api_get(base_url: str, path: str) -> dict:
|
||||
req = urllib.request.Request(f"{base_url}{path}")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def api_post(base_url: str, path: str, body: dict | None = None) -> dict:
|
||||
data = json.dumps(body or {}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def call_claude(user_message: str, model: str, timeout_s: float) -> tuple[str | None, str | None]:
|
||||
if not shutil.which("claude"):
|
||||
return None, "claude CLI not available"
|
||||
args = [
|
||||
"claude", "-p",
|
||||
"--model", model,
|
||||
"--append-system-prompt", SYSTEM_PROMPT,
|
||||
"--disable-slash-commands",
|
||||
user_message,
|
||||
]
|
||||
last_error = ""
|
||||
for attempt in range(3):
|
||||
if attempt > 0:
|
||||
time.sleep(2 ** attempt)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
args, capture_output=True, text=True,
|
||||
timeout=timeout_s, cwd=get_sandbox_cwd(),
|
||||
encoding="utf-8", errors="replace",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
last_error = f"{model} timed out"
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_error = f"subprocess error: {exc}"
|
||||
continue
|
||||
if completed.returncode == 0:
|
||||
return (completed.stdout or "").strip(), None
|
||||
stderr = (completed.stderr or "").strip()[:200]
|
||||
last_error = f"{model} exit {completed.returncode}: {stderr}"
|
||||
return None, last_error
|
||||
|
||||
|
||||
def fetch_tag_distribution(base_url: str) -> dict[str, int]:
|
||||
"""Count tag occurrences across active memories (client-side)."""
|
||||
try:
|
||||
result = api_get(base_url, "/memory?active_only=true&limit=2000")
|
||||
except Exception as e:
|
||||
print(f"ERROR: could not fetch memories: {e}", file=sys.stderr)
|
||||
return {}
|
||||
mems = result.get("memories", [])
|
||||
counts: dict[str, int] = {}
|
||||
for m in mems:
|
||||
tags = m.get("domain_tags") or []
|
||||
if isinstance(tags, str):
|
||||
try:
|
||||
tags = json.loads(tags)
|
||||
except Exception:
|
||||
tags = []
|
||||
if not isinstance(tags, list):
|
||||
continue
|
||||
for t in tags:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
key = t.strip().lower()
|
||||
if key:
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Phase 7C tag canonicalization detector")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL)
|
||||
parser.add_argument("--timeout-s", type=float, default=DEFAULT_TIMEOUT_S)
|
||||
parser.add_argument("--no-auto-approve", action="store_true",
|
||||
help="Disable autonomous apply; all proposals → human queue")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Print decisions without touching state")
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.base_url.rstrip("/")
|
||||
autonomous = not args.no_auto_approve
|
||||
|
||||
print(
|
||||
f"canonicalize_tags {TAG_CANON_PROMPT_VERSION} | model={args.model} | "
|
||||
f"autonomous={autonomous} | auto-approve conf>={AUTO_APPROVE_CONF}"
|
||||
)
|
||||
|
||||
dist = fetch_tag_distribution(base)
|
||||
print(f"tag distribution: {len(dist)} unique tags, "
|
||||
f"{sum(dist.values())} total references")
|
||||
if not dist:
|
||||
print("no tags found — nothing to canonicalize")
|
||||
return
|
||||
|
||||
user_msg = build_user_message(dist)
|
||||
raw, err = call_claude(user_msg, args.model, args.timeout_s)
|
||||
if err or raw is None:
|
||||
print(f"ERROR: LLM call failed: {err}", file=sys.stderr)
|
||||
return
|
||||
|
||||
aliases_raw = parse_canon_output(raw)
|
||||
print(f"LLM returned {len(aliases_raw)} raw alias proposals")
|
||||
|
||||
auto_applied = 0
|
||||
auto_skipped_missing_canonical = 0
|
||||
proposals_created = 0
|
||||
duplicates_skipped = 0
|
||||
|
||||
for item in aliases_raw:
|
||||
norm = normalize_alias_item(item)
|
||||
if norm is None:
|
||||
continue
|
||||
alias = norm["alias"]
|
||||
canonical = norm["canonical"]
|
||||
confidence = norm["confidence"]
|
||||
|
||||
alias_count = dist.get(alias, 0)
|
||||
canonical_count = dist.get(canonical, 0)
|
||||
|
||||
# Sanity: alias must actually exist in the current distribution
|
||||
if alias_count < MIN_ALIAS_COUNT:
|
||||
print(f" SKIP {alias!r} → {canonical!r}: alias not in distribution")
|
||||
continue
|
||||
if canonical_count == 0:
|
||||
auto_skipped_missing_canonical += 1
|
||||
print(f" SKIP {alias!r} → {canonical!r}: canonical missing from distribution")
|
||||
continue
|
||||
|
||||
label = f"{alias!r} ({alias_count}) → {canonical!r} ({canonical_count}) conf={confidence:.2f}"
|
||||
|
||||
auto_apply = autonomous and confidence >= AUTO_APPROVE_CONF
|
||||
if auto_apply:
|
||||
if args.dry_run:
|
||||
auto_applied += 1
|
||||
print(f" [dry-run] would auto-apply: {label}")
|
||||
continue
|
||||
try:
|
||||
result = api_post(base, "/admin/tags/aliases/apply", {
|
||||
"alias": alias, "canonical": canonical,
|
||||
"confidence": confidence, "reason": norm["reason"],
|
||||
"alias_count": alias_count, "canonical_count": canonical_count,
|
||||
"actor": "auto-tag-canon",
|
||||
})
|
||||
touched = result.get("memories_touched", 0)
|
||||
auto_applied += 1
|
||||
print(f" ✅ auto-applied: {label} ({touched} memories)")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ auto-apply failed: {label} — {e}", file=sys.stderr)
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
|
||||
# Lower confidence → human review
|
||||
if args.dry_run:
|
||||
proposals_created += 1
|
||||
print(f" [dry-run] would propose for review: {label}")
|
||||
continue
|
||||
try:
|
||||
result = api_post(base, "/admin/tags/aliases/propose", {
|
||||
"alias": alias, "canonical": canonical,
|
||||
"confidence": confidence, "reason": norm["reason"],
|
||||
"alias_count": alias_count, "canonical_count": canonical_count,
|
||||
})
|
||||
if result.get("proposal_id"):
|
||||
proposals_created += 1
|
||||
print(f" → pending proposal: {label}")
|
||||
else:
|
||||
duplicates_skipped += 1
|
||||
print(f" (duplicate pending proposal): {label}")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ propose failed: {label} — {e}", file=sys.stderr)
|
||||
time.sleep(0.2)
|
||||
|
||||
print(
|
||||
f"\nsummary: proposals_seen={len(aliases_raw)} "
|
||||
f"auto_applied={auto_applied} "
|
||||
f"proposals_created={proposals_created} "
|
||||
f"duplicates_skipped={duplicates_skipped} "
|
||||
f"skipped_missing_canonical={auto_skipped_missing_canonical}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
223
scripts/detect_emerging.py
Normal file
223
scripts/detect_emerging.py
Normal file
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase 6 C.1 — Emerging-concepts detector (HTTP-only).
|
||||
|
||||
Scans active + candidate memories via the HTTP API to surface:
|
||||
1. Unregistered projects — project strings appearing on 3+ memories
|
||||
that aren't in the project registry. Surface for one-click
|
||||
registration.
|
||||
2. Emerging categories — top 20 domain_tags by frequency, for
|
||||
"what themes are emerging in my work?" intelligence.
|
||||
3. Reinforced transients — active memories with reference_count >= 5
|
||||
AND valid_until set. These "were temporary but now durable"; a
|
||||
sibling endpoint (/admin/memory/extend-reinforced) actually
|
||||
performs the extension.
|
||||
|
||||
Writes results to project_state under atocore/proposals/* via the API.
|
||||
Runs host-side (cron calls it) so uses stdlib only — no atocore deps.
|
||||
|
||||
Usage:
|
||||
python3 scripts/detect_emerging.py [--base-url URL] [--dry-run]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
PROJECT_MIN_MEMORIES = int(os.environ.get("ATOCORE_EMERGING_PROJECT_MIN", "3"))
|
||||
PROJECT_ALERT_THRESHOLD = int(os.environ.get("ATOCORE_EMERGING_ALERT_THRESHOLD", "5"))
|
||||
TOP_TAGS_LIMIT = int(os.environ.get("ATOCORE_EMERGING_TOP_TAGS", "20"))
|
||||
|
||||
|
||||
def api_get(base_url: str, path: str, timeout: int = 30) -> dict:
|
||||
req = urllib.request.Request(f"{base_url}{path}")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def api_post(base_url: str, path: str, body: dict, timeout: int = 10) -> dict:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def fetch_registered_project_names(base_url: str) -> set[str]:
|
||||
"""Set of all registered project ids + aliases, lowercased."""
|
||||
try:
|
||||
result = api_get(base_url, "/projects")
|
||||
except Exception as e:
|
||||
print(f"WARN: could not load project registry: {e}", file=sys.stderr)
|
||||
return set()
|
||||
registered = set()
|
||||
for p in result.get("projects", []):
|
||||
pid = (p.get("project_id") or p.get("id") or p.get("name") or "").strip()
|
||||
if pid:
|
||||
registered.add(pid.lower())
|
||||
for alias in p.get("aliases", []) or []:
|
||||
if isinstance(alias, str) and alias.strip():
|
||||
registered.add(alias.strip().lower())
|
||||
return registered
|
||||
|
||||
|
||||
def fetch_memories(base_url: str, status: str, limit: int = 500) -> list[dict]:
|
||||
try:
|
||||
params = f"limit={limit}"
|
||||
if status == "active":
|
||||
params += "&active_only=true"
|
||||
else:
|
||||
params += f"&status={status}"
|
||||
result = api_get(base_url, f"/memory?{params}")
|
||||
return result.get("memories", [])
|
||||
except Exception as e:
|
||||
print(f"WARN: could not fetch {status} memories: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
|
||||
def fetch_previous_proposals(base_url: str) -> list[dict]:
|
||||
"""Read last run's unregistered_projects to diff against this run."""
|
||||
try:
|
||||
result = api_get(base_url, "/project/state/atocore")
|
||||
entries = result.get("entries", result.get("state", []))
|
||||
for e in entries:
|
||||
if e.get("category") == "proposals" and e.get("key") == "unregistered_projects_prev":
|
||||
try:
|
||||
return json.loads(e.get("value") or "[]")
|
||||
except Exception:
|
||||
return []
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def set_state(base_url: str, category: str, key: str, value: str, source: str = "emerging detector") -> None:
|
||||
api_post(base_url, "/project/state", {
|
||||
"project": "atocore",
|
||||
"category": category,
|
||||
"key": key,
|
||||
"value": value,
|
||||
"source": source,
|
||||
})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Detect emerging projects + categories")
|
||||
parser.add_argument("--base-url", default=os.environ.get("ATOCORE_BASE_URL", "http://127.0.0.1:8100"))
|
||||
parser.add_argument("--dry-run", action="store_true", help="Report without writing to project state")
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.base_url.rstrip("/")
|
||||
|
||||
registered = fetch_registered_project_names(base)
|
||||
active = fetch_memories(base, "active")
|
||||
candidates = fetch_memories(base, "candidate")
|
||||
all_mems = active + candidates
|
||||
|
||||
# --- Unregistered projects ---
|
||||
project_mems: dict[str, list] = defaultdict(list)
|
||||
for m in all_mems:
|
||||
proj = (m.get("project") or "").strip().lower()
|
||||
if not proj or proj in registered:
|
||||
continue
|
||||
project_mems[proj].append(m)
|
||||
|
||||
unregistered = []
|
||||
for proj, mems in sorted(project_mems.items()):
|
||||
if len(mems) < PROJECT_MIN_MEMORIES:
|
||||
continue
|
||||
unregistered.append({
|
||||
"project": proj,
|
||||
"count": len(mems),
|
||||
"sample_memory_ids": [m.get("id") for m in mems[:3]],
|
||||
"sample_contents": [(m.get("content") or "")[:150] for m in mems[:3]],
|
||||
})
|
||||
|
||||
# --- Emerging domain_tags (active only) ---
|
||||
tag_counter: Counter = Counter()
|
||||
for m in active:
|
||||
for t in (m.get("domain_tags") or []):
|
||||
if isinstance(t, str) and t.strip():
|
||||
tag_counter[t.strip().lower()] += 1
|
||||
emerging_tags = [{"tag": tag, "count": cnt} for tag, cnt in tag_counter.most_common(TOP_TAGS_LIMIT)]
|
||||
|
||||
# --- Reinforced transients (active, high refs, has expiry) ---
|
||||
reinforced = []
|
||||
for m in active:
|
||||
ref_count = int(m.get("reference_count") or 0)
|
||||
vu = (m.get("valid_until") or "").strip()
|
||||
if ref_count >= 5 and vu:
|
||||
reinforced.append({
|
||||
"memory_id": m.get("id"),
|
||||
"reference_count": ref_count,
|
||||
"valid_until": vu,
|
||||
"content_preview": (m.get("content") or "")[:150],
|
||||
"project": m.get("project") or "",
|
||||
})
|
||||
|
||||
result = {
|
||||
"unregistered_projects": unregistered,
|
||||
"emerging_categories": emerging_tags,
|
||||
"reinforced_transients": reinforced,
|
||||
"counts": {
|
||||
"active_memories": len(active),
|
||||
"candidate_memories": len(candidates),
|
||||
"unregistered_project_count": len(unregistered),
|
||||
"emerging_tag_count": len(emerging_tags),
|
||||
"reinforced_transient_count": len(reinforced),
|
||||
},
|
||||
}
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
if args.dry_run:
|
||||
return
|
||||
|
||||
# --- Persist to project state via HTTP ---
|
||||
try:
|
||||
set_state(base, "proposals", "unregistered_projects", json.dumps(unregistered))
|
||||
set_state(base, "proposals", "emerging_categories", json.dumps(emerging_tags))
|
||||
set_state(base, "proposals", "reinforced_transients", json.dumps(reinforced))
|
||||
except Exception as e:
|
||||
print(f"WARN: failed to persist proposals: {e}", file=sys.stderr)
|
||||
|
||||
# --- Alert on NEW projects crossing the threshold ---
|
||||
try:
|
||||
prev = fetch_previous_proposals(base)
|
||||
prev_names = {p.get("project") for p in prev if isinstance(p, dict)}
|
||||
newly_crossed = [
|
||||
p for p in unregistered
|
||||
if p["count"] >= PROJECT_ALERT_THRESHOLD
|
||||
and p["project"] not in prev_names
|
||||
]
|
||||
if newly_crossed:
|
||||
names = ", ".join(p["project"] for p in newly_crossed)
|
||||
# Use existing alert mechanism via state (Phase 4 infra)
|
||||
try:
|
||||
set_state(base, "alert", "last_warning", json.dumps({
|
||||
"title": f"Emerging project(s) detected: {names}",
|
||||
"message": (
|
||||
f"{len(newly_crossed)} unregistered project(s) crossed "
|
||||
f"the {PROJECT_ALERT_THRESHOLD}-memory threshold. "
|
||||
f"Review at /wiki or /admin/dashboard."
|
||||
),
|
||||
"timestamp": "",
|
||||
}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Snapshot for next run's diff
|
||||
set_state(base, "proposals", "unregistered_projects_prev", json.dumps(unregistered))
|
||||
except Exception as e:
|
||||
print(f"WARN: alert/state write failed: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
scripts/eval_data/candidate_queue_2026-04-12.json
Normal file
1
scripts/eval_data/candidate_queue_2026-04-12.json
Normal file
File diff suppressed because one or more lines are too long
29
scripts/eval_data/candidate_queue_2026-04-12.txt
Normal file
29
scripts/eval_data/candidate_queue_2026-04-12.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
1. [project ] proj=atocore AtoCore extraction must stay off the hot capture path; batch endpoint only
|
||||
2. [project ] proj=atocore Auto-promote gate: confidence ≥0.8 AND no duplicate in active memories
|
||||
3. [project ] proj=atocore AtoCore LLM extraction pipeline deployed on Dalidou host, runs via cron at 03:00 UTC via scripts/batch_llm_extract_live.py
|
||||
4. [project ] proj=atocore LLM extractor runs host-side (not in container) because claude CLI not available in container environment
|
||||
5. [project ] proj=atocore Host-side extraction script scripts/batch_llm_extract_live.py uses pure stdlib, no atocore imports for deployment simplicity
|
||||
6. [project ] proj=atocore POST /admin/extract-batch accepts mode: rule|llm, POST /interactions/{id}/extract now mode-aware
|
||||
7. [knowledge ] proj=atocore claude CLI 2.0.60 removed --no-session-persistence flag, extraction sessions now persist in claude history
|
||||
8. [adaptation ] proj=atocore Durable memory extraction candidates must be <200 chars, stand-alone, typed as project|knowledge|preference|adaptation
|
||||
9. [adaptation ] proj=atocore Memory extraction confidence defaults to 0.5, raise to 0.6 only for unambiguous committed claims
|
||||
10. [project ] proj=atocore Live Dalidou is on commit 39d73e9, not e2895b5
|
||||
11. [project ] proj=atocore Live harness is reproducible at 16/18 PASS
|
||||
12. [project ] proj=atocore Live active memories count is 36
|
||||
13. [project ] proj=atocore Wave 2 project-state entries on live: p04=5, p05=6, p06=6
|
||||
14. [project ] proj=atocore R6 is fixed by commit 39d73e9
|
||||
15. [project ] proj=atocore R9: R6 fix only covers empty project fallback; wrong non-empty model project can still override known interaction scope
|
||||
16. [project ] proj=atocore R10: Phase 8 is baseline-complete but not primary-complete; OpenClaw client covers narrow read-oriented slice of API
|
||||
17. [project ] proj=atocore Phase 8 is decent baseline integration milestone but not primary-ready yet
|
||||
18. [project ] proj=atocore 4-step roadmap complete: extractor → harness → Wave 2 → OpenClaw
|
||||
19. [project ] proj=atocore Codex audit loop proven across two full round-trips in one session
|
||||
20. [project ] proj=atocore Session end state: 36 active memories, 17 project-state entries, 16/18 harness, 280 tests, main at 54d84b5
|
||||
21. [project ] proj=atocore AtoCore extraction stays off the hot capture path; LLM extraction runs as scheduled batch, not inline with POST /interactions.
|
||||
22. [project ] proj=atocore AtoCore auto-triage trust model: auto-promote only when confidence ≥0.8 AND no duplicate active memory; else needs_human.
|
||||
23. [project ] proj=atocore Multi-model triage: use different model for triage reviewer than extractor (sonnet for extract)
|
||||
24. [project ] proj=atocore R9 fix: when interaction has known project, prefer it over model's non-matching project unless model's is registered
|
||||
25. [project ] proj=atocore R7 ranking fix: add overlap-density as secondary signal (overlap_count / memory_token_count)
|
||||
26. [project ] proj=atocore Extraction pipeline skips interactions with response_chars < 50 to avoid low-signal content
|
||||
27. [project ] proj=atocore AtoCore triage uses independent model from extractor (extractor: sonnet, triage: different model or different prompt).
|
||||
28. [project ] proj=atocore AtoCore ranking scorer adds overlap-density (overlap_count / memory_tokens) as secondary signal to fix short-memory ranking.
|
||||
29. [project ] proj=atocore AtoCore project trust: when interaction has known project and model returns different project, prefer interaction's project unless
|
||||
51
scripts/eval_data/candidate_queue_snapshot.jsonl
Normal file
51
scripts/eval_data/candidate_queue_snapshot.jsonl
Normal file
@@ -0,0 +1,51 @@
|
||||
{"id": "0dd85386-cace-4f9a-9098-c6732f3c64fa", "type": "project", "project": "atocore", "confidence": 0.5, "content": "AtoCore roadmap: (1) extractor improvement, (2) harness expansion, (3) Wave 2 ingestion, (4) OpenClaw finish; steps 1+2 are current mini-phase"}
|
||||
{"id": "8939b875-152c-4c90-8614-3cfdc64cd1d6", "type": "knowledge", "project": "atocore", "confidence": 0.5, "content": "AtoCore is FastAPI (Python 3.12, SQLite + ChromaDB) on Dalidou home server (dalidou:8100), repo C:\\Users\\antoi\\ATOCore, data /srv/storage/atocore/, ingests Obsidian vault + Google Drive into vector memory system."}
|
||||
{"id": "93e37d2a-b512-4a97-b230-e64ac913d087", "type": "knowledge", "project": "atocore", "confidence": 0.5, "content": "Deploy AtoCore: git push origin main, then ssh papa@dalidou and run /srv/storage/atocore/app/deploy/dalidou/deploy.sh"}
|
||||
{"id": "4b82fe01-4393-464a-b935-9ad5d112d3d8", "type": "adaptation", "project": "atocore", "confidence": 0.5, "content": "Do not add memory extraction to interaction capture hot path; keep extraction as separate batch/manual step. Reason: latency and queue noise before review rhythm is comfortable."}
|
||||
{"id": "c873ec00-063e-488c-ad32-1233290a3feb", "type": "project", "project": "atocore", "confidence": 0.5, "content": "As of 2026-04-11, approved roadmap in order: observe reinforcement, batch extraction, candidate triage, off-Dalidou backup, retrieval quality review."}
|
||||
{"id": "665cdd27-0057-4e73-82f5-5d4f47189b5d", "type": "project", "project": "atocore", "confidence": 0.5, "content": "AtoCore adopts DEV-LEDGER.md as shared operating memory with stable headers; updated at session boundaries"}
|
||||
{"id": "5f89c51d-7e8b-4fb9-830d-a35bb649f9f7", "type": "adaptation", "project": "atocore", "confidence": 0.5, "content": "Codex branches for AtoCore fork from main (never orphan); use naming pattern codex/<topic>"}
|
||||
{"id": "25ac367c-8bbe-4ba4-8d8e-d533db33f2d9", "type": "adaptation", "project": "atocore", "confidence": 0.5, "content": "In AtoCore, Claude builds and Codex audits; never work in parallel on same files"}
|
||||
{"id": "89446ebe-fd42-4177-80db-3657bc41d048", "type": "adaptation", "project": "atocore", "confidence": 0.5, "content": "In AtoCore, P1-severity findings in DEV-LEDGER.md block further main commits until acknowledged"}
|
||||
{"id": "1f077e98-f945-4480-96ab-110b0671ebc6", "type": "adaptation", "project": "atocore", "confidence": 0.5, "content": "Every AtoCore session appends to DEV-LEDGER.md Session Log and updates Orientation before ending"}
|
||||
{"id": "89f60018-c23b-4b2f-80ca-e6f7d02c5cd3", "type": "preference", "project": "atocore", "confidence": 0.5, "content": "User prefers receiving standalone testing prompts they can paste into Claude Code on target deployments rather than having the assistant run tests directly."}
|
||||
{"id": "2f69a6ed-6de2-4565-87df-1ea3e8c42963", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "USB SSD on RPi is mandatory for polishing telemetry storage; must be independent of network for data integrity during runs."}
|
||||
{"id": "6bcaebde-9e45-4de5-a220-65d9c4cd451e", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Use Tailscale mesh for RPi remote access to provide SSH, file transfer, and NAT traversal without port forwarding."}
|
||||
{"id": "82f17880-92da-485e-a24a-0599ab1836e7", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Auto-sync telemetry data via rsync over Tailscale after runs complete; fire-and-forget pattern with automatic retry on network interruption."}
|
||||
{"id": "2dd36f74-db47-4c72-a185-fec025d07d4f", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Real-time telemetry monitoring should target 10 Hz downsampling; full 100 Hz streaming over network is not necessary."}
|
||||
{"id": "7519d82b-8065-41f0-812e-9c1a3573d7b9", "type": "knowledge", "project": "p06-polisher", "confidence": 0.5, "content": "Polishing telemetry data rate is approximately 29 MB per hour (100 Hz × 20 channels × 4 bytes = 8 KB/s)."}
|
||||
{"id": "78678162-5754-478b-b1fc-e25f22e0ee03", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Machine spec (shareable) + Atomaste spec (internal) separate concerns. Machine spec hides program generation as 'separate scope' to protect IP/business strategy."}
|
||||
{"id": "6657b4ae-d4ec-4fec-a66f-2975cdb10d13", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Firmware interface contract is invariant: controller-job.v1 input, run-log.v1 + telemetry output. No firmware changes needed regardless of program generation implementation."}
|
||||
{"id": "6d6f4fe9-73e5-449f-a802-6dc0a974f87b", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Atomaste sim spec documents forward/return paths, calibration model (Preston k), translation loss, and service/IP strategy—details hidden from shareable machine spec."}
|
||||
{"id": "932f38df-58f3-49c2-9968-8d422dc54b42", "type": "project", "project": "", "confidence": 0.5, "content": "USB SSD mandatory for storage (not SD card); directory structure /data/runs/{id}/, /data/manual/{id}/; status.json for machine state"}
|
||||
{"id": "2b3178e8-fe38-4338-b2b0-75a01da18cea", "type": "project", "project": "", "confidence": 0.5, "content": "RPi joins Tailscale mesh for remote access over SSH VPN; no public IP or port forwarding; fully offline operation"}
|
||||
{"id": "254c394d-3f80-4b34-a891-9f1cbfec74d7", "type": "project", "project": "", "confidence": 0.5, "content": "Data synchronization via rsync over Tailscale, failure-tolerant and non-blocking; USB stick as manual fallback"}
|
||||
{"id": "ee626650-1ee0-439c-85c9-6d32a876f239", "type": "project", "project": "", "confidence": 0.5, "content": "Machine design principle: works fully offline and independently; network connection is for remote access only"}
|
||||
{"id": "34add99d-8d2e-4586-b002-fc7b7d22bcb3", "type": "project", "project": "", "confidence": 0.5, "content": "No cloud, no real-time streaming, no remote control features in design scope"}
|
||||
{"id": "993e0afe-9910-4984-b608-f5e9de7c0453", "type": "project", "project": "atocore", "confidence": 0.5, "content": "P1: Reflection loop integration incomplete—extraction remains manual (POST /interactions/{id}/extract), not auto-triggered with reinforcement. Live capture won't auto-populate candidate review queue."}
|
||||
{"id": "bdf488d7-9200-441e-afbf-5335020ea78b", "type": "project", "project": "atocore", "confidence": 0.5, "content": "P1: Project memories excluded from context injection; build_context() requests [\"identity\", \"preference\"] only. Reinforcement signal doesn't reach assembled context packs."}
|
||||
{"id": "188197af-a61d-4616-9e39-712aeaaadf61", "type": "project", "project": "atocore", "confidence": 0.5, "content": "Current batch-extract rules produce only 1 candidate from 42 real captures. Extractor needs conversational-cue detection or LLM-assisted path to improve yield."}
|
||||
{"id": "acffcaa4-5966-4ec1-a0b2-3b8dcebe75bd", "type": "project", "project": "atocore", "confidence": 0.5, "content": "Next priority: extractor rule expansion (cheapest validation of reflection loop), then Wave 2 trusted operational ingestion (master-plan priority). Defer retrieval eval harness focus."}
|
||||
{"id": "1b44a886-a5af-4426-bf10-a92baf3a6502", "type": "knowledge", "project": "atocore", "confidence": 0.5, "content": "Alias canonicalization fix (resolve_project_name() boundary) is consistently applied across project state, memories, interactions, and context lookup. Code review approved directionally."}
|
||||
{"id": "e8f4e704-367b-4759-b20c-da0ccf06cf7d", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Machine capabilities now define z_type: engage_retract and cam_type: mechanical_with_encoder instead of actuator-driven setpoints."}
|
||||
{"id": "ab2b607c-52b1-405f-a874-c6078393c21c", "type": "knowledge", "project": "", "confidence": 0.5, "content": "Codex is an audit agent; communicate with it via markdown prompts with numbered steps; it updates findings via commits to codex/* branches or direct messages."}
|
||||
{"id": "5a5fd29d-291f-4e22-88fe-825cf55f745a", "type": "preference", "project": "", "confidence": 0.5, "content": "Audit-first workflow recommended: have codex audit DEV-LEDGER.md and recent commits before execution; validates round-trip, catches errors early."}
|
||||
{"id": "4c238106-017e-4283-99a1-639497b6ddde", "type": "knowledge", "project": "", "confidence": 0.5, "content": "DEV-LEDGER.md at repo root is the shared coordination document with Orientation, Active Plan, and Open Review Findings sections."}
|
||||
{"id": "83aed988-4257-4220-b612-6c725d6cd95a", "type": "project", "project": "atocore", "confidence": 0.5, "content": "Roadmap: Extractor improvement → Harness expansion → Wave 2 trusted operational ingestion → Finish OpenClaw integration (in that order)"}
|
||||
{"id": "95d87d1a-5daa-414d-95ff-a344a62e0b6b", "type": "project", "project": "atocore", "confidence": 0.5, "content": "Phase 1 (Extractor): eval-driven loop—label captures, improve rules/add LLM mode, measure yield & FP, stop when queue reviewable (not coverage metrics)"}
|
||||
{"id": "7aafb588-51b0-4536-a414-ebaaea924b98", "type": "project", "project": "atocore", "confidence": 0.5, "content": "Phases 1 & 2 (Extractor + Harness) are a mini-phase; without harness, extractor improvements are blind edits"}
|
||||
{"id": "aa50c51a-27d7-4db9-b7a3-7ca75dba2118", "type": "knowledge", "project": "", "confidence": 0.5, "content": "Dalidou stores Claude Code interactions via a Stop hook that fires after each turn and POSTs to http://dalidou:8100/interactions with client=claude-code parameter"}
|
||||
{"id": "5951108b-3a5e-49d0-9308-dfab449664d3", "type": "adaptation", "project": "", "confidence": 0.5, "content": "Interaction capture system is passive and automatic; no manual action required, interactions accumulate automatically during normal Claude Code usage"}
|
||||
{"id": "9d2cbbe9-cf2e-4aab-9cb8-c4951da70826", "type": "project", "project": "", "confidence": 0.5, "content": "Session Log/Ledger system tracks work state across sessions so future sessions immediately know what is true and what is next; phases marked by git SHAs."}
|
||||
{"id": "db88eecf-e31a-4fee-b07d-0b51db7e315e", "type": "project", "project": "atocore", "confidence": 0.5, "content": "atocore uses multi-model coordination: Claude and codex share DEV-LEDGER.md (current state / active plan / P1+P2 findings / recent decisions / commit log) read at session start, appended at session end"}
|
||||
{"id": "8748f071-ff28-47a6-8504-65ca30a8336a", "type": "project", "project": "atocore", "confidence": 0.5, "content": "atocore starts with manual-event-loop (/audit or /status prompts) using DEV-LEDGER.md before upgrading to automated git hooks/CI review"}
|
||||
{"id": "f9210883-67a8-4dae-9f27-6b5ae7bd8a6b", "type": "project", "project": "atocore", "confidence": 0.5, "content": "atocore development involves coordinating between Claude and codex models with shared plan/review strategy and counter-validation to improve system quality"}
|
||||
{"id": "85f008b9-2d6d-49ad-81a1-e254dac2a2ac", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Z-axis is a binary engage/retract mechanism (z_engaged bool), not continuous position control; confirmation timeout z_engage_timeout_s required."}
|
||||
{"id": "0cc417ed-ac38-4231-9786-a9582ac6a60f", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Cam amplitude and offset are mechanically set by operator and read via encoders; no actuators control them, controller receives encoder telemetry only."}
|
||||
{"id": "2e001aaf-0c5c-4547-9b96-ebc4172b258d", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Cam parameters in controller are expected_cam_amplitude_deg and expected_cam_offset_deg (read-only reference for verification), not command setpoints."}
|
||||
{"id": "47778126-b0cf-41d9-9e21-f2418f53e792", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Manual mode UI displays cam encoder readings (cam_amplitude_deg, cam_offset_deg) as read-only for operator verification of mechanical setting."}
|
||||
{"id": "410e4a70-ae12-4de2-8f31-071ffee3cad4", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Manual session log records cam_setting measured at session start; run-log segment actual block includes cam_amplitude_deg_mean and cam_offset_deg_mean."}
|
||||
{"id": "e94f94f0-3538-40dd-aef2-0189eacc7eb7", "type": "knowledge", "project": "atocore", "confidence": 0.5, "content": "AtoCore deployments to dalidou use the script /srv/storage/atocore/app/deploy/dalidou/deploy.sh instead of manual docker commands"}
|
||||
{"id": "23fa6fdf-cfb9-4850-ad04-3ea56551c30a", "type": "project", "project": "", "confidence": 0.5, "content": "Retrieval/extraction evaluation follows 8-day mini-phase plan with hard gates to prevent scope drift. Preflight checks must validate git SHAs, baselines, and fixture stability before coding."}
|
||||
{"id": "3e1fad28-031b-4670-a9d0-0af2e8ba1361", "type": "project", "project": "", "confidence": 0.5, "content": "Day 1: Create labeled extractor eval set from 30 captures (10 zero-candidate, 10 single-candidate, 10 ambiguous) with metadata; create scoring tool to measure precision/recall."}
|
||||
{"id": "d49378a4-d03c-4730-be87-f0fcb2d199db", "type": "project", "project": "", "confidence": 0.5, "content": "Day 2: Measure current extractor against labeled set, recording yield, true/false positives, and false negatives by pattern."}
|
||||
145
scripts/eval_data/extractor_labels_2026-04-11.json
Normal file
145
scripts/eval_data/extractor_labels_2026-04-11.json
Normal file
@@ -0,0 +1,145 @@
|
||||
{
|
||||
"version": "0.1",
|
||||
"frozen_at": "2026-04-11",
|
||||
"snapshot_file": "scripts/eval_data/interactions_snapshot_2026-04-11.json",
|
||||
"labeled_count": 20,
|
||||
"plan_deviation": "Codex's plan called for 30 labeled interactions (10 zero / 10 plausible / 10 ambiguous). Actual corpus is heavily skewed toward instructional/status content; after reading 20 drawn by length-stratified random sample, the honest positive rate is ~25% (5/20). Labeling more would mostly add zeros; the Day 2 measurement is not bottlenecked on sample size.",
|
||||
"positive_count": 5,
|
||||
"labels": [
|
||||
{
|
||||
"id": "ab239158-d6ac-4c51-b6e4-dd4ccea384a2",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Instructional deploy guidance. No durable claim."
|
||||
},
|
||||
{
|
||||
"id": "da153f2a-b20a-4dee-8c72-431ebb71f08c",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "'Deploy still in progress.' Pure status."
|
||||
},
|
||||
{
|
||||
"id": "7d8371ee-c6d3-4dfe-a7b0-2d091f075c15",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Git command walkthrough. No durable claim."
|
||||
},
|
||||
{
|
||||
"id": "14bf3f90-e318-466e-81ac-d35522741ba5",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Ledger status update. Transient fact, not a durable memory candidate."
|
||||
},
|
||||
{
|
||||
"id": "8f855235-c38d-4c27-9f2b-8530ebe1a2d8",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Short-term recommendation ('merge to main and deploy'), not a standing decision."
|
||||
},
|
||||
{
|
||||
"id": "04a96eb5-cd00-4e9f-9252-b2cc919000a4",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Dev server config table. Operational detail, not a memory."
|
||||
},
|
||||
{
|
||||
"id": "79d606ed-8981-454a-83af-c25226b1b65c",
|
||||
"expected_count": 1,
|
||||
"expected_type": "adaptation",
|
||||
"expected_project": "",
|
||||
"expected_snippet": "shared DEV-LEDGER as operating memory",
|
||||
"miss_class": "recommendation_prose",
|
||||
"notes": "A recommendation that later became a ratified decision. Rule extractor would need a 'simplest version that could work today' / 'I'd start with' cue class."
|
||||
},
|
||||
{
|
||||
"id": "a6b0d279-c564-4bce-a703-e476f4a148ad",
|
||||
"expected_count": 2,
|
||||
"expected_type": "project",
|
||||
"expected_project": "p06-polisher",
|
||||
"expected_snippet": "z_engaged bool; cam amplitude set mechanically and read by encoders",
|
||||
"miss_class": "architectural_change_summary",
|
||||
"notes": "Two durable architectural facts about the polisher machine (Z-axis is engage/retract, cam is read-only). Extractor would need to recognize 'A is now B' / 'X removed, Y added' patterns."
|
||||
},
|
||||
{
|
||||
"id": "4e00e398-2e89-4653-8ee5-3f65c7f4d2d3",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Clarification question to user."
|
||||
},
|
||||
{
|
||||
"id": "a6a7816a-7590-4616-84f4-49d9054c2a91",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Instructional response offering two next moves."
|
||||
},
|
||||
{
|
||||
"id": "03527502-316a-4a3e-989c-00719392c7d1",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Troubleshooting a paste failure. Ephemeral."
|
||||
},
|
||||
{
|
||||
"id": "1fff59fc-545f-42df-9dd1-a0e6dec1b7ee",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Agreement + follow-up question. No durable claim."
|
||||
},
|
||||
{
|
||||
"id": "eb65dc18-0030-4720-ace7-f55af9df719d",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Explanation of how the capture hook works. Instructional."
|
||||
},
|
||||
{
|
||||
"id": "52c8c0f3-32fb-4b48-9065-73c778a08417",
|
||||
"expected_count": 1,
|
||||
"expected_type": "project",
|
||||
"expected_project": "p06-polisher",
|
||||
"expected_snippet": "USB SSD mandatory on RPi; Tailscale for remote access",
|
||||
"miss_class": "spec_update_announcement",
|
||||
"notes": "Concrete architectural commitments just added to the polisher spec. Phrased as '§17.1 Local Storage - USB SSD mandatory, not SD card.' The '§' section markers could be a new cue."
|
||||
},
|
||||
{
|
||||
"id": "32d40414-15af-47ee-944b-2cceae9574b8",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Session recap. Historical summary, not a durable memory."
|
||||
},
|
||||
{
|
||||
"id": "b6d2cdfc-37fb-459a-96bd-caefb9beaab4",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Deployment prompt for Dalidou. Operational, not a memory."
|
||||
},
|
||||
{
|
||||
"id": "ee03d823-931b-4d4e-9258-88b4ed5eeb07",
|
||||
"expected_count": 2,
|
||||
"expected_type": "knowledge",
|
||||
"expected_project": "p06-polisher",
|
||||
"expected_snippet": "USB SSD is non-negotiable for local storage; Tailscale mesh for SSH/file transfer",
|
||||
"miss_class": "layered_recommendation",
|
||||
"notes": "Layered infra recommendation with 'non-negotiable' / 'strongly recommended' strength markers. The 'non-negotiable' token could be a new cue class."
|
||||
},
|
||||
{
|
||||
"id": "dd234d9f-0d1c-47e8-b01c-eebcb568c7e7",
|
||||
"expected_count": 1,
|
||||
"expected_type": "project",
|
||||
"expected_project": "p06-polisher",
|
||||
"expected_snippet": "interface contract is identical regardless of who generates the programs; machine is a standalone box",
|
||||
"miss_class": "alignment_assertion",
|
||||
"notes": "Architectural invariant assertion. '**Alignment verified**' / 'nothing changes for X' style. Likely too subtle for rule matching without LLM assistance."
|
||||
},
|
||||
{
|
||||
"id": "1f95891a-cf37-400e-9d68-4fad8e04dcbb",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Huge session handoff prompt. Informational only."
|
||||
},
|
||||
{
|
||||
"id": "5580950f-d010-4544-be4b-b3071271a698",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Ledger schema sketch. Structural design proposal, later ratified — but the same idea was already captured as a ratified decision in the recent decisions section, so not worth re-extracting from this conversational form."
|
||||
}
|
||||
]
|
||||
}
|
||||
518
scripts/eval_data/extractor_llm_baseline_2026-04-11.json
Normal file
518
scripts/eval_data/extractor_llm_baseline_2026-04-11.json
Normal file
@@ -0,0 +1,518 @@
|
||||
{
|
||||
"summary": {
|
||||
"total": 20,
|
||||
"exact_match": 6,
|
||||
"positive_expected": 5,
|
||||
"total_expected_candidates": 7,
|
||||
"total_actual_candidates": 51,
|
||||
"yield_rate": 2.55,
|
||||
"recall": 1.0,
|
||||
"precision": 0.357,
|
||||
"false_positive_interactions": 9,
|
||||
"false_negative_interactions": 0,
|
||||
"miss_classes": {},
|
||||
"mode": "llm"
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"id": "ab239158-d6ac-4c51-b6e4-dd4ccea384a2",
|
||||
"expected_count": 0,
|
||||
"actual_count": 1,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Instructional deploy guidance. No durable claim.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "AtoCore deployments to dalidou use the script /srv/storage/atocore/app/deploy/dalidou/deploy.sh instead of manual docker commands",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "da153f2a-b20a-4dee-8c72-431ebb71f08c",
|
||||
"expected_count": 0,
|
||||
"actual_count": 0,
|
||||
"ok": true,
|
||||
"miss_class": "n/a",
|
||||
"notes": "'Deploy still in progress.' Pure status.",
|
||||
"actual_candidates": []
|
||||
},
|
||||
{
|
||||
"id": "7d8371ee-c6d3-4dfe-a7b0-2d091f075c15",
|
||||
"expected_count": 0,
|
||||
"actual_count": 0,
|
||||
"ok": true,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Git command walkthrough. No durable claim.",
|
||||
"actual_candidates": []
|
||||
},
|
||||
{
|
||||
"id": "14bf3f90-e318-466e-81ac-d35522741ba5",
|
||||
"expected_count": 0,
|
||||
"actual_count": 4,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Ledger status update. Transient fact, not a durable memory candidate.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Retrieval/extraction evaluation follows 8-day mini-phase plan with hard gates to prevent scope drift. Preflight checks must validate git SHAs, baselines, and fixture stability before coding.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Day 1: Create labeled extractor eval set from 30 captures (10 zero-candidate, 10 single-candidate, 10 ambiguous) with metadata; create scoring tool to measure precision/recall.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Day 2: Measure current extractor against labeled set, recording yield, true/false positives, and false negatives by pattern.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Session Log/Ledger system tracks work state across sessions so future sessions immediately know what is true and what is next; phases marked by git SHAs.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "8f855235-c38d-4c27-9f2b-8530ebe1a2d8",
|
||||
"expected_count": 0,
|
||||
"actual_count": 0,
|
||||
"ok": true,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Short-term recommendation ('merge to main and deploy'), not a standing decision.",
|
||||
"actual_candidates": []
|
||||
},
|
||||
{
|
||||
"id": "04a96eb5-cd00-4e9f-9252-b2cc919000a4",
|
||||
"expected_count": 0,
|
||||
"actual_count": 0,
|
||||
"ok": true,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Dev server config table. Operational detail, not a memory.",
|
||||
"actual_candidates": []
|
||||
},
|
||||
{
|
||||
"id": "79d606ed-8981-454a-83af-c25226b1b65c",
|
||||
"expected_count": 1,
|
||||
"actual_count": 3,
|
||||
"ok": false,
|
||||
"miss_class": "recommendation_prose",
|
||||
"notes": "A recommendation that later became a ratified decision. Rule extractor would need a 'simplest version that could work today' / 'I'd start with' cue class.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "atocore uses multi-model coordination: Claude and codex share DEV-LEDGER.md (current state / active plan / P1+P2 findings / recent decisions / commit log) read at session start, appended at session end",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "atocore starts with manual-event-loop (/audit or /status prompts) using DEV-LEDGER.md before upgrading to automated git hooks/CI review",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "atocore development involves coordinating between Claude and codex models with shared plan/review strategy and counter-validation to improve system quality",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a6b0d279-c564-4bce-a703-e476f4a148ad",
|
||||
"expected_count": 2,
|
||||
"actual_count": 6,
|
||||
"ok": false,
|
||||
"miss_class": "architectural_change_summary",
|
||||
"notes": "Two durable architectural facts about the polisher machine (Z-axis is engage/retract, cam is read-only). Extractor would need to recognize 'A is now B' / 'X removed, Y added' patterns.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Z-axis is a binary engage/retract mechanism (z_engaged bool), not continuous position control; confirmation timeout z_engage_timeout_s required.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Cam amplitude and offset are mechanically set by operator and read via encoders; no actuators control them, controller receives encoder telemetry only.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Cam parameters in controller are expected_cam_amplitude_deg and expected_cam_offset_deg (read-only reference for verification), not command setpoints.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Manual mode UI displays cam encoder readings (cam_amplitude_deg, cam_offset_deg) as read-only for operator verification of mechanical setting.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Manual session log records cam_setting measured at session start; run-log segment actual block includes cam_amplitude_deg_mean and cam_offset_deg_mean.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Machine capabilities now define z_type: engage_retract and cam_type: mechanical_with_encoder instead of actuator-driven setpoints.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "4e00e398-2e89-4653-8ee5-3f65c7f4d2d3",
|
||||
"expected_count": 0,
|
||||
"actual_count": 0,
|
||||
"ok": true,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Clarification question to user.",
|
||||
"actual_candidates": []
|
||||
},
|
||||
{
|
||||
"id": "a6a7816a-7590-4616-84f4-49d9054c2a91",
|
||||
"expected_count": 0,
|
||||
"actual_count": 3,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Instructional response offering two next moves.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "Codex is an audit agent; communicate with it via markdown prompts with numbered steps; it updates findings via commits to codex/* branches or direct messages.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "preference",
|
||||
"content": "Audit-first workflow recommended: have codex audit DEV-LEDGER.md and recent commits before execution; validates round-trip, catches errors early.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "DEV-LEDGER.md at repo root is the shared coordination document with Orientation, Active Plan, and Open Review Findings sections.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "03527502-316a-4a3e-989c-00719392c7d1",
|
||||
"expected_count": 0,
|
||||
"actual_count": 0,
|
||||
"ok": true,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Troubleshooting a paste failure. Ephemeral.",
|
||||
"actual_candidates": []
|
||||
},
|
||||
{
|
||||
"id": "1fff59fc-545f-42df-9dd1-a0e6dec1b7ee",
|
||||
"expected_count": 0,
|
||||
"actual_count": 3,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Agreement + follow-up question. No durable claim.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Roadmap: Extractor improvement → Harness expansion → Wave 2 trusted operational ingestion → Finish OpenClaw integration (in that order)",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Phase 1 (Extractor): eval-driven loop—label captures, improve rules/add LLM mode, measure yield & FP, stop when queue reviewable (not coverage metrics)",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Phases 1 & 2 (Extractor + Harness) are a mini-phase; without harness, extractor improvements are blind edits",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eb65dc18-0030-4720-ace7-f55af9df719d",
|
||||
"expected_count": 0,
|
||||
"actual_count": 2,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Explanation of how the capture hook works. Instructional.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "Dalidou stores Claude Code interactions via a Stop hook that fires after each turn and POSTs to http://dalidou:8100/interactions with client=claude-code parameter",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "adaptation",
|
||||
"content": "Interaction capture system is passive and automatic; no manual action required, interactions accumulate automatically during normal Claude Code usage",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "52c8c0f3-32fb-4b48-9065-73c778a08417",
|
||||
"expected_count": 1,
|
||||
"actual_count": 5,
|
||||
"ok": false,
|
||||
"miss_class": "spec_update_announcement",
|
||||
"notes": "Concrete architectural commitments just added to the polisher spec. Phrased as '§17.1 Local Storage - USB SSD mandatory, not SD card.' The '§' section markers could be a new cue.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "USB SSD mandatory for storage (not SD card); directory structure /data/runs/{id}/, /data/manual/{id}/; status.json for machine state",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "RPi joins Tailscale mesh for remote access over SSH VPN; no public IP or port forwarding; fully offline operation",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Data synchronization via rsync over Tailscale, failure-tolerant and non-blocking; USB stick as manual fallback",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Machine design principle: works fully offline and independently; network connection is for remote access only",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "No cloud, no real-time streaming, no remote control features in design scope",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "32d40414-15af-47ee-944b-2cceae9574b8",
|
||||
"expected_count": 0,
|
||||
"actual_count": 5,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Session recap. Historical summary, not a durable memory.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "P1: Reflection loop integration incomplete—extraction remains manual (POST /interactions/{id}/extract), not auto-triggered with reinforcement. Live capture won't auto-populate candidate review queue.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "P1: Project memories excluded from context injection; build_context() requests [\"identity\", \"preference\"] only. Reinforcement signal doesn't reach assembled context packs.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Current batch-extract rules produce only 1 candidate from 42 real captures. Extractor needs conversational-cue detection or LLM-assisted path to improve yield.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Next priority: extractor rule expansion (cheapest validation of reflection loop), then Wave 2 trusted operational ingestion (master-plan priority). Defer retrieval eval harness focus.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "Alias canonicalization fix (resolve_project_name() boundary) is consistently applied across project state, memories, interactions, and context lookup. Code review approved directionally.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b6d2cdfc-37fb-459a-96bd-caefb9beaab4",
|
||||
"expected_count": 0,
|
||||
"actual_count": 1,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Deployment prompt for Dalidou. Operational, not a memory.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "preference",
|
||||
"content": "User prefers receiving standalone testing prompts they can paste into Claude Code on target deployments rather than having the assistant run tests directly.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ee03d823-931b-4d4e-9258-88b4ed5eeb07",
|
||||
"expected_count": 2,
|
||||
"actual_count": 5,
|
||||
"ok": false,
|
||||
"miss_class": "layered_recommendation",
|
||||
"notes": "Layered infra recommendation with 'non-negotiable' / 'strongly recommended' strength markers. The 'non-negotiable' token could be a new cue class.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "USB SSD on RPi is mandatory for polishing telemetry storage; must be independent of network for data integrity during runs.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Use Tailscale mesh for RPi remote access to provide SSH, file transfer, and NAT traversal without port forwarding.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Auto-sync telemetry data via rsync over Tailscale after runs complete; fire-and-forget pattern with automatic retry on network interruption.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Real-time telemetry monitoring should target 10 Hz downsampling; full 100 Hz streaming over network is not necessary.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "Polishing telemetry data rate is approximately 29 MB per hour (100 Hz × 20 channels × 4 bytes = 8 KB/s).",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dd234d9f-0d1c-47e8-b01c-eebcb568c7e7",
|
||||
"expected_count": 1,
|
||||
"actual_count": 3,
|
||||
"ok": false,
|
||||
"miss_class": "alignment_assertion",
|
||||
"notes": "Architectural invariant assertion. '**Alignment verified**' / 'nothing changes for X' style. Likely too subtle for rule matching without LLM assistance.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Machine spec (shareable) + Atomaste spec (internal) separate concerns. Machine spec hides program generation as 'separate scope' to protect IP/business strategy.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Firmware interface contract is invariant: controller-job.v1 input, run-log.v1 + telemetry output. No firmware changes needed regardless of program generation implementation.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Atomaste sim spec documents forward/return paths, calibration model (Preston k), translation loss, and service/IP strategy—details hidden from shareable machine spec.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "1f95891a-cf37-400e-9d68-4fad8e04dcbb",
|
||||
"expected_count": 0,
|
||||
"actual_count": 4,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Huge session handoff prompt. Informational only.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "AtoCore is FastAPI (Python 3.12, SQLite + ChromaDB) on Dalidou home server (dalidou:8100), repo C:\\Users\\antoi\\ATOCore, data /srv/storage/atocore/, ingests Obsidian vault + Google Drive into vector memory system.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "Deploy AtoCore: git push origin main, then ssh papa@dalidou and run /srv/storage/atocore/app/deploy/dalidou/deploy.sh",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "adaptation",
|
||||
"content": "Do not add memory extraction to interaction capture hot path; keep extraction as separate batch/manual step. Reason: latency and queue noise before review rhythm is comfortable.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "As of 2026-04-11, approved roadmap in order: observe reinforcement, batch extraction, candidate triage, off-Dalidou backup, retrieval quality review.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "5580950f-d010-4544-be4b-b3071271a698",
|
||||
"expected_count": 0,
|
||||
"actual_count": 6,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Ledger schema sketch. Structural design proposal, later ratified — but the same idea was already captured as a ratified decision in the recent decisions section, so not worth re-extracting from this conversational form.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "AtoCore adopts DEV-LEDGER.md as shared operating memory with stable headers; updated at session boundaries",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "adaptation",
|
||||
"content": "Codex branches for AtoCore fork from main (never orphan); use naming pattern codex/<topic>",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "adaptation",
|
||||
"content": "In AtoCore, Claude builds and Codex audits; never work in parallel on same files",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "adaptation",
|
||||
"content": "In AtoCore, P1-severity findings in DEV-LEDGER.md block further main commits until acknowledged",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "adaptation",
|
||||
"content": "Every AtoCore session appends to DEV-LEDGER.md Session Log and updates Orientation before ending",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "AtoCore roadmap: (1) extractor improvement, (2) harness expansion, (3) Wave 2 ingestion, (4) OpenClaw finish; steps 1+2 are current mini-phase",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
scripts/eval_data/interactions_snapshot_2026-04-11.json
Normal file
1
scripts/eval_data/interactions_snapshot_2026-04-11.json
Normal file
File diff suppressed because one or more lines are too long
1
scripts/eval_data/triage_verdict_2026-04-12.json
Normal file
1
scripts/eval_data/triage_verdict_2026-04-12.json
Normal file
@@ -0,0 +1 @@
|
||||
{"promote": ["4b82fe01-4393-464a-b935-9ad5d112d3d8", "665cdd27-0057-4e73-82f5-5d4f47189b5d", "5f89c51d-7e8b-4fb9-830d-a35bb649f9f7", "25ac367c-8bbe-4ba4-8d8e-d533db33f2d9", "2f69a6ed-6de2-4565-87df-1ea3e8c42963", "6bcaebde-9e45-4de5-a220-65d9c4cd451e", "2dd36f74-db47-4c72-a185-fec025d07d4f", "7519d82b-8065-41f0-812e-9c1a3573d7b9", "78678162-5754-478b-b1fc-e25f22e0ee03", "6657b4ae-d4ec-4fec-a66f-2975cdb10d13", "ee626650-1ee0-439c-85c9-6d32a876f239", "1b44a886-a5af-4426-bf10-a92baf3a6502", "aa50c51a-27d7-4db9-b7a3-7ca75dba2118", "5951108b-3a5e-49d0-9308-dfab449664d3", "85f008b9-2d6d-49ad-81a1-e254dac2a2ac", "0cc417ed-ac38-4231-9786-a9582ac6a60f"], "reject": ["0dd85386-cace-4f9a-9098-c6732f3c64fa", "8939b875-152c-4c90-8614-3cfdc64cd1d6", "93e37d2a-b512-4a97-b230-e64ac913d087", "c873ec00-063e-488c-ad32-1233290a3feb", "89446ebe-fd42-4177-80db-3657bc41d048", "1f077e98-f945-4480-96ab-110b0671ebc6", "89f60018-c23b-4b2f-80ca-e6f7d02c5cd3", "82f17880-92da-485e-a24a-0599ab1836e7", "6d6f4fe9-73e5-449f-a802-6dc0a974f87b", "932f38df-58f3-49c2-9968-8d422dc54b42", "2b3178e8-fe38-4338-b2b0-75a01da18cea", "254c394d-3f80-4b34-a891-9f1cbfec74d7", "34add99d-8d2e-4586-b002-fc7b7d22bcb3", "993e0afe-9910-4984-b608-f5e9de7c0453", "bdf488d7-9200-441e-afbf-5335020ea78b", "188197af-a61d-4616-9e39-712aeaaadf61", "acffcaa4-5966-4ec1-a0b2-3b8dcebe75bd", "e8f4e704-367b-4759-b20c-da0ccf06cf7d", "ab2b607c-52b1-405f-a874-c6078393c21c", "5a5fd29d-291f-4e22-88fe-825cf55f745a", "4c238106-017e-4283-99a1-639497b6ddde", "83aed988-4257-4220-b612-6c725d6cd95a", "95d87d1a-5daa-414d-95ff-a344a62e0b6b", "7aafb588-51b0-4536-a414-ebaaea924b98", "9d2cbbe9-cf2e-4aab-9cb8-c4951da70826", "db88eecf-e31a-4fee-b07d-0b51db7e315e", "8748f071-ff28-47a6-8504-65ca30a8336a", "f9210883-67a8-4dae-9f27-6b5ae7bd8a6b", "2e001aaf-0c5c-4547-9b96-ebc4172b258d", "47778126-b0cf-41d9-9e21-f2418f53e792", "410e4a70-ae12-4de2-8f31-071ffee3cad4", "e94f94f0-3538-40dd-aef2-0189eacc7eb7", "23fa6fdf-cfb9-4850-ad04-3ea56551c30a", "3e1fad28-031b-4670-a9d0-0af2e8ba1361", "d49378a4-d03c-4730-be87-f0fcb2d199db"]}
|
||||
274
scripts/extractor_eval.py
Normal file
274
scripts/extractor_eval.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""Extractor eval runner — scores the rule-based extractor against a
|
||||
labeled interaction corpus.
|
||||
|
||||
Pulls full interaction content from a frozen snapshot, runs each through
|
||||
``extract_candidates_from_interaction``, and compares the output to the
|
||||
expected counts from a labels file. Produces a per-label scorecard plus
|
||||
aggregate precision / recall / yield numbers.
|
||||
|
||||
This harness deliberately stays file-based: snapshot + labels + this
|
||||
runner. No Dalidou HTTP dependency once the snapshot is frozen, so the
|
||||
eval is reproducible run-to-run even as live captures drift.
|
||||
|
||||
Usage:
|
||||
|
||||
python scripts/extractor_eval.py # human report
|
||||
python scripts/extractor_eval.py --json # machine-readable
|
||||
python scripts/extractor_eval.py \\
|
||||
--snapshot scripts/eval_data/interactions_snapshot_2026-04-11.json \\
|
||||
--labels scripts/eval_data/extractor_labels_2026-04-11.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
# Force UTF-8 on stdout so real LLM output (arrows, em-dashes, CJK)
|
||||
# doesn't crash the human report on Windows cp1252 consoles.
|
||||
if hasattr(sys.stdout, "buffer"):
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True)
|
||||
|
||||
# Make src/ importable without requiring an install.
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(_REPO_ROOT / "src"))
|
||||
|
||||
from atocore.interactions.service import Interaction # noqa: E402
|
||||
from atocore.memory.extractor import extract_candidates_from_interaction # noqa: E402
|
||||
from atocore.memory.extractor_llm import extract_candidates_llm # noqa: E402
|
||||
|
||||
DEFAULT_SNAPSHOT = _REPO_ROOT / "scripts" / "eval_data" / "interactions_snapshot_2026-04-11.json"
|
||||
DEFAULT_LABELS = _REPO_ROOT / "scripts" / "eval_data" / "extractor_labels_2026-04-11.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LabelResult:
|
||||
id: str
|
||||
expected_count: int
|
||||
actual_count: int
|
||||
ok: bool
|
||||
miss_class: str
|
||||
notes: str
|
||||
actual_candidates: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def load_snapshot(path: Path) -> dict[str, dict]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return {item["id"]: item for item in data.get("interactions", [])}
|
||||
|
||||
|
||||
def load_labels(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def interaction_from_snapshot(snap: dict) -> Interaction:
|
||||
return Interaction(
|
||||
id=snap["id"],
|
||||
prompt=snap.get("prompt", "") or "",
|
||||
response=snap.get("response", "") or "",
|
||||
response_summary="",
|
||||
project=snap.get("project", "") or "",
|
||||
client=snap.get("client", "") or "",
|
||||
session_id=snap.get("session_id", "") or "",
|
||||
created_at=snap.get("created_at", "") or "",
|
||||
)
|
||||
|
||||
|
||||
def score(snapshot: dict[str, dict], labels_doc: dict, mode: str = "rule") -> list[LabelResult]:
|
||||
results: list[LabelResult] = []
|
||||
for label in labels_doc["labels"]:
|
||||
iid = label["id"]
|
||||
snap = snapshot.get(iid)
|
||||
if snap is None:
|
||||
results.append(
|
||||
LabelResult(
|
||||
id=iid,
|
||||
expected_count=int(label.get("expected_count", 0)),
|
||||
actual_count=-1,
|
||||
ok=False,
|
||||
miss_class="not_in_snapshot",
|
||||
notes=label.get("notes", ""),
|
||||
)
|
||||
)
|
||||
continue
|
||||
interaction = interaction_from_snapshot(snap)
|
||||
if mode == "llm":
|
||||
candidates = extract_candidates_llm(interaction)
|
||||
else:
|
||||
candidates = extract_candidates_from_interaction(interaction)
|
||||
actual_count = len(candidates)
|
||||
expected_count = int(label.get("expected_count", 0))
|
||||
results.append(
|
||||
LabelResult(
|
||||
id=iid,
|
||||
expected_count=expected_count,
|
||||
actual_count=actual_count,
|
||||
ok=(actual_count == expected_count),
|
||||
miss_class=label.get("miss_class", "n/a"),
|
||||
notes=label.get("notes", ""),
|
||||
actual_candidates=[
|
||||
{
|
||||
"memory_type": c.memory_type,
|
||||
"content": c.content,
|
||||
"project": c.project,
|
||||
"rule": c.rule,
|
||||
}
|
||||
for c in candidates
|
||||
],
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def aggregate(results: list[LabelResult]) -> dict:
|
||||
total = len(results)
|
||||
exact_match = sum(1 for r in results if r.ok)
|
||||
true_positive = sum(1 for r in results if r.expected_count > 0 and r.actual_count > 0)
|
||||
false_positive_interactions = sum(
|
||||
1 for r in results if r.expected_count == 0 and r.actual_count > 0
|
||||
)
|
||||
false_negative_interactions = sum(
|
||||
1 for r in results if r.expected_count > 0 and r.actual_count == 0
|
||||
)
|
||||
positive_expected = sum(1 for r in results if r.expected_count > 0)
|
||||
total_expected_candidates = sum(r.expected_count for r in results)
|
||||
total_actual_candidates = sum(max(r.actual_count, 0) for r in results)
|
||||
yield_rate = total_actual_candidates / total if total else 0.0
|
||||
# Recall over interaction count that had at least one expected candidate:
|
||||
recall = true_positive / positive_expected if positive_expected else 0.0
|
||||
# Precision over interaction count that produced any candidate:
|
||||
precision_denom = true_positive + false_positive_interactions
|
||||
precision = true_positive / precision_denom if precision_denom else 0.0
|
||||
# Miss class breakdown
|
||||
miss_classes: dict[str, int] = {}
|
||||
for r in results:
|
||||
if r.expected_count > 0 and r.actual_count == 0:
|
||||
key = r.miss_class or "unlabeled"
|
||||
miss_classes[key] = miss_classes.get(key, 0) + 1
|
||||
return {
|
||||
"total": total,
|
||||
"exact_match": exact_match,
|
||||
"positive_expected": positive_expected,
|
||||
"total_expected_candidates": total_expected_candidates,
|
||||
"total_actual_candidates": total_actual_candidates,
|
||||
"yield_rate": round(yield_rate, 3),
|
||||
"recall": round(recall, 3),
|
||||
"precision": round(precision, 3),
|
||||
"false_positive_interactions": false_positive_interactions,
|
||||
"false_negative_interactions": false_negative_interactions,
|
||||
"miss_classes": miss_classes,
|
||||
}
|
||||
|
||||
|
||||
def print_human(results: list[LabelResult], summary: dict) -> None:
|
||||
print("=== Extractor eval ===")
|
||||
print(
|
||||
f"labeled={summary['total']} "
|
||||
f"exact_match={summary['exact_match']} "
|
||||
f"positive_expected={summary['positive_expected']}"
|
||||
)
|
||||
print(
|
||||
f"yield={summary['yield_rate']} "
|
||||
f"recall={summary['recall']} "
|
||||
f"precision={summary['precision']}"
|
||||
)
|
||||
print(
|
||||
f"false_positives={summary['false_positive_interactions']} "
|
||||
f"false_negatives={summary['false_negative_interactions']}"
|
||||
)
|
||||
print()
|
||||
print("miss class breakdown (FN):")
|
||||
if summary["miss_classes"]:
|
||||
for k, v in sorted(summary["miss_classes"].items(), key=lambda kv: -kv[1]):
|
||||
print(f" {v:3d} {k}")
|
||||
else:
|
||||
print(" (none)")
|
||||
print()
|
||||
print("per-interaction:")
|
||||
for r in results:
|
||||
marker = "OK " if r.ok else "MISS"
|
||||
iid_short = r.id[:8]
|
||||
print(f" {marker} {iid_short} expected={r.expected_count} actual={r.actual_count} class={r.miss_class}")
|
||||
if r.actual_candidates:
|
||||
for c in r.actual_candidates:
|
||||
preview = (c["content"] or "")[:80]
|
||||
print(f" [{c['memory_type']}] {preview}")
|
||||
|
||||
|
||||
def print_json(results: list[LabelResult], summary: dict) -> None:
|
||||
payload = {
|
||||
"summary": summary,
|
||||
"results": [
|
||||
{
|
||||
"id": r.id,
|
||||
"expected_count": r.expected_count,
|
||||
"actual_count": r.actual_count,
|
||||
"ok": r.ok,
|
||||
"miss_class": r.miss_class,
|
||||
"notes": r.notes,
|
||||
"actual_candidates": r.actual_candidates,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
json.dump(payload, sys.stdout, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="AtoCore extractor eval")
|
||||
parser.add_argument("--snapshot", type=Path, default=DEFAULT_SNAPSHOT)
|
||||
parser.add_argument("--labels", type=Path, default=DEFAULT_LABELS)
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="write JSON result to this file (bypasses log/stdout interleaving)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["rule", "llm"],
|
||||
default="rule",
|
||||
help="which extractor to score (default: rule)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
snapshot = load_snapshot(args.snapshot)
|
||||
labels = load_labels(args.labels)
|
||||
results = score(snapshot, labels, mode=args.mode)
|
||||
summary = aggregate(results)
|
||||
summary["mode"] = args.mode
|
||||
|
||||
if args.output is not None:
|
||||
payload = {
|
||||
"summary": summary,
|
||||
"results": [
|
||||
{
|
||||
"id": r.id,
|
||||
"expected_count": r.expected_count,
|
||||
"actual_count": r.actual_count,
|
||||
"ok": r.ok,
|
||||
"miss_class": r.miss_class,
|
||||
"notes": r.notes,
|
||||
"actual_candidates": r.actual_candidates,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
args.output.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"wrote {args.output} ({summary['mode']}: recall={summary['recall']} precision={summary['precision']})")
|
||||
elif args.json:
|
||||
print_json(results, summary)
|
||||
else:
|
||||
print_human(results, summary)
|
||||
|
||||
return 0 if summary["false_negative_interactions"] == 0 and summary["false_positive_interactions"] == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
237
scripts/graduate_memories.py
Normal file
237
scripts/graduate_memories.py
Normal file
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase 5F — Memory → Entity graduation batch pass.
|
||||
|
||||
Takes active memories, asks claude-p whether each describes a typed
|
||||
engineering entity, and creates entity candidates for the ones that do.
|
||||
Each candidate carries source_refs back to its source memory so human
|
||||
review can trace provenance.
|
||||
|
||||
Human reviews the entity candidates via /admin/triage (same UI as memory
|
||||
triage). When a candidate is promoted, a post-promote hook marks the source
|
||||
memory as `graduated` and sets `graduated_to_entity_id` for traceability.
|
||||
|
||||
This is THE population move: without it, the engineering graph stays sparse
|
||||
and the killer queries (Q-006/009/011) have nothing to find gaps in.
|
||||
|
||||
Usage:
|
||||
python3 scripts/graduate_memories.py --base-url http://127.0.0.1:8100 \\
|
||||
--project p05-interferometer --limit 20
|
||||
|
||||
# Dry run (don't create entities, just show decisions):
|
||||
python3 scripts/graduate_memories.py --project p05-interferometer --dry-run
|
||||
|
||||
# Process all active memories across all projects (big run):
|
||||
python3 scripts/graduate_memories.py --limit 200
|
||||
|
||||
Host-side because claude CLI lives on Dalidou, not in the container.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
# Make src/ importable so we can reuse the stdlib-only prompt module
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_SRC_DIR = os.path.abspath(os.path.join(_SCRIPT_DIR, "..", "src"))
|
||||
if _SRC_DIR not in sys.path:
|
||||
sys.path.insert(0, _SRC_DIR)
|
||||
|
||||
from atocore.engineering._graduation_prompt import ( # noqa: E402
|
||||
GRADUATION_PROMPT_VERSION,
|
||||
SYSTEM_PROMPT,
|
||||
build_user_message,
|
||||
parse_graduation_output,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://127.0.0.1:8100")
|
||||
DEFAULT_MODEL = os.environ.get("ATOCORE_LLM_EXTRACTOR_MODEL", "sonnet")
|
||||
DEFAULT_TIMEOUT_S = float(os.environ.get("ATOCORE_GRADUATION_TIMEOUT_S", "90"))
|
||||
|
||||
_sandbox_cwd = None
|
||||
|
||||
|
||||
def get_sandbox_cwd() -> str:
|
||||
"""Temp cwd so claude CLI doesn't auto-discover project CLAUDE.md files."""
|
||||
global _sandbox_cwd
|
||||
if _sandbox_cwd is None:
|
||||
_sandbox_cwd = tempfile.mkdtemp(prefix="ato-graduate-")
|
||||
return _sandbox_cwd
|
||||
|
||||
|
||||
def api_get(base_url: str, path: str) -> dict:
|
||||
req = urllib.request.Request(f"{base_url}{path}")
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def api_post(base_url: str, path: str, body: dict | None = None) -> dict:
|
||||
data = json.dumps(body or {}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def graduate_one(memory: dict, model: str, timeout_s: float) -> dict[str, Any] | None:
|
||||
"""Ask claude whether this memory describes a typed entity.
|
||||
|
||||
Returns None on any failure (parse error, timeout, exit!=0).
|
||||
Applies retry+pacing to match the pattern in auto_triage/batch_extract.
|
||||
"""
|
||||
if not shutil.which("claude"):
|
||||
return None
|
||||
|
||||
user_msg = build_user_message(
|
||||
memory_content=memory.get("content", "") or "",
|
||||
memory_project=memory.get("project", "") or "",
|
||||
memory_type=memory.get("memory_type", "") or "",
|
||||
)
|
||||
|
||||
args = [
|
||||
"claude", "-p",
|
||||
"--model", model,
|
||||
"--append-system-prompt", SYSTEM_PROMPT,
|
||||
"--disable-slash-commands",
|
||||
user_msg,
|
||||
]
|
||||
|
||||
last_error = ""
|
||||
for attempt in range(3):
|
||||
if attempt > 0:
|
||||
time.sleep(2 ** attempt)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
args, capture_output=True, text=True,
|
||||
timeout=timeout_s, cwd=get_sandbox_cwd(),
|
||||
encoding="utf-8", errors="replace",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
last_error = "timeout"
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_error = f"subprocess error: {exc}"
|
||||
continue
|
||||
|
||||
if completed.returncode == 0:
|
||||
return parse_graduation_output(completed.stdout or "")
|
||||
|
||||
stderr = (completed.stderr or "").strip()[:200]
|
||||
last_error = f"exit_{completed.returncode}: {stderr}" if stderr else f"exit_{completed.returncode}"
|
||||
|
||||
print(f" ! claude failed after 3 tries: {last_error}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def create_entity_candidate(
|
||||
base_url: str,
|
||||
decision: dict,
|
||||
memory: dict,
|
||||
) -> str | None:
|
||||
"""Create an entity candidate with source_refs pointing at the memory."""
|
||||
try:
|
||||
result = api_post(base_url, "/entities", {
|
||||
"entity_type": decision["entity_type"],
|
||||
"name": decision["name"],
|
||||
"project": memory.get("project", "") or "",
|
||||
"description": decision["description"],
|
||||
"properties": {
|
||||
"graduated_from_memory": memory["id"],
|
||||
"proposed_relationships": decision["relationships"],
|
||||
"prompt_version": GRADUATION_PROMPT_VERSION,
|
||||
},
|
||||
"status": "candidate",
|
||||
"confidence": decision["confidence"],
|
||||
"source_refs": [f"memory:{memory['id']}"],
|
||||
})
|
||||
return result.get("id")
|
||||
except Exception as e:
|
||||
print(f" ! entity create failed: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Graduate active memories into entity candidates")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL)
|
||||
parser.add_argument("--project", default=None, help="Only graduate memories in this project")
|
||||
parser.add_argument("--limit", type=int, default=50, help="Max memories to process")
|
||||
parser.add_argument("--min-confidence", type=float, default=0.3,
|
||||
help="Skip memories with confidence below this (they're probably noise)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Show decisions without creating entities")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Fetch active memories
|
||||
query = "status=active"
|
||||
query += f"&limit={args.limit}"
|
||||
if args.project:
|
||||
query += f"&project={args.project}"
|
||||
result = api_get(args.base_url, f"/memory?{query}")
|
||||
memories = result.get("memories", [])
|
||||
|
||||
# Filter by min_confidence + skip already-graduated
|
||||
memories = [m for m in memories
|
||||
if m.get("confidence", 0) >= args.min_confidence
|
||||
and m.get("status") != "graduated"]
|
||||
|
||||
print(f"graduating: {len(memories)} memories project={args.project or '(all)'} "
|
||||
f"model={args.model} dry_run={args.dry_run}")
|
||||
|
||||
graduated = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
entities_created: list[str] = []
|
||||
|
||||
for i, mem in enumerate(memories, 1):
|
||||
if i > 1:
|
||||
time.sleep(0.5) # light pacing, matches auto_triage
|
||||
mid = mem["id"]
|
||||
label = f"[{i:3d}/{len(memories)}] {mid[:8]} [{mem.get('memory_type','?')}]"
|
||||
|
||||
decision = graduate_one(mem, args.model, DEFAULT_TIMEOUT_S)
|
||||
if decision is None:
|
||||
print(f" ERROR {label} (graduate_one returned None)")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if not decision.get("graduate"):
|
||||
reason = decision.get("reason", "(no reason)")
|
||||
print(f" skip {label} {reason}")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
etype = decision["entity_type"]
|
||||
ename = decision["name"]
|
||||
nrel = len(decision.get("relationships", []))
|
||||
|
||||
if args.dry_run:
|
||||
print(f" WOULD {label} → [{etype}] {ename!r} ({nrel} rels)")
|
||||
graduated += 1
|
||||
else:
|
||||
entity_id = create_entity_candidate(args.base_url, decision, mem)
|
||||
if entity_id:
|
||||
print(f" CREATE {label} → [{etype}] {ename!r} ({nrel} rels) entity={entity_id[:8]}")
|
||||
graduated += 1
|
||||
entities_created.append(entity_id)
|
||||
else:
|
||||
errors += 1
|
||||
|
||||
print(f"\ntotal: graduated={graduated} skipped={skipped} errors={errors}")
|
||||
if entities_created:
|
||||
print(f"Review at /admin/triage ({len(entities_created)} entity candidates created)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
254
scripts/import_openclaw_state.py
Normal file
254
scripts/import_openclaw_state.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""OpenClaw state importer — one-way pull from clawdbot into AtoCore.
|
||||
|
||||
Reads OpenClaw's file continuity layer (SOUL.md, USER.md, MODEL-ROUTING.md,
|
||||
MEMORY.md, memory/YYYY-MM-DD.md) from the T420 via SSH and imports them
|
||||
into AtoCore as candidate memories. Hash-based delta detection — only
|
||||
re-imports files that changed since the last run.
|
||||
|
||||
Classification per codex's integration proposal:
|
||||
- SOUL.md -> identity candidates
|
||||
- USER.md -> identity + preference candidates
|
||||
- MODEL-ROUTING.md -> adaptation candidates (routing rules)
|
||||
- MEMORY.md -> long-term memory candidates (type varies)
|
||||
- memory/YYYY-MM-DD.md -> episodic memory candidates (daily logs)
|
||||
- heartbeat-state.json -> skipped (ops metadata only)
|
||||
|
||||
All candidates land as status=candidate. Auto-triage filters noise.
|
||||
This importer is conservative: it doesn't promote directly, it just
|
||||
feeds signal. The triage pipeline decides what graduates to active.
|
||||
|
||||
Usage:
|
||||
python3 scripts/import_openclaw_state.py \
|
||||
--base-url http://localhost:8100 \
|
||||
--openclaw-host papa@192.168.86.39 \
|
||||
--openclaw-path /home/papa/openclaw-workspace
|
||||
|
||||
Runs nightly via cron (added as Step 2c in cron-backup.sh).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://localhost:8100")
|
||||
DEFAULT_OPENCLAW_HOST = os.environ.get("ATOCORE_OPENCLAW_HOST", "papa@192.168.86.39")
|
||||
DEFAULT_OPENCLAW_PATH = os.environ.get("ATOCORE_OPENCLAW_PATH", "/home/papa/clawd")
|
||||
|
||||
# Files to pull and how to classify them
|
||||
DURABLE_FILES = [
|
||||
("SOUL.md", "identity"),
|
||||
("USER.md", "identity"),
|
||||
("MODEL-ROUTING.md", "adaptation"),
|
||||
("MEMORY.md", "memory"), # type parsed from entries
|
||||
]
|
||||
DAILY_MEMORY_GLOB = "memory/*.md"
|
||||
HASH_STATE_KEY = "openclaw_import_hashes"
|
||||
|
||||
|
||||
def api_get(base_url, path):
|
||||
try:
|
||||
with urllib.request.urlopen(f"{base_url}{path}", timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def api_post(base_url, path, body):
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 400:
|
||||
return {"skipped": True}
|
||||
raise
|
||||
|
||||
|
||||
def ssh_cat(host, remote_path):
|
||||
"""Cat a remote file via SSH. Returns content or None if missing."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes",
|
||||
host, f"cat {remote_path}"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
encoding="utf-8", errors="replace",
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def ssh_ls(host, remote_glob):
|
||||
"""List files matching a glob on the remote host."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes",
|
||||
host, f"ls -1 {remote_glob} 2>/dev/null"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
encoding="utf-8", errors="replace",
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def content_hash(text):
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def load_hash_state(base_url):
|
||||
"""Load the hash state from project_state so we know what's changed."""
|
||||
state = api_get(base_url, "/project/state/atocore?category=status")
|
||||
if not state:
|
||||
return {}
|
||||
for entry in state.get("entries", []):
|
||||
if entry.get("key") == HASH_STATE_KEY:
|
||||
try:
|
||||
return json.loads(entry["value"])
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_hash_state(base_url, hashes):
|
||||
api_post(base_url, "/project/state", {
|
||||
"project": "atocore",
|
||||
"category": "status",
|
||||
"key": HASH_STATE_KEY,
|
||||
"value": json.dumps(hashes),
|
||||
"source": "import_openclaw_state.py",
|
||||
})
|
||||
|
||||
|
||||
def import_file_as_memory(base_url, filename, content, memory_type, source_tag):
|
||||
"""Import a file's content as a single candidate memory for triage."""
|
||||
# Trim to reasonable size — auto-triage can handle long content but
|
||||
# we don't want single mega-memories dominating the queue
|
||||
trimmed = content[:2000]
|
||||
if len(content) > 2000:
|
||||
trimmed += f"\n\n[...truncated from {len(content)} chars]"
|
||||
|
||||
body = {
|
||||
"memory_type": memory_type,
|
||||
"content": f"From OpenClaw/{filename}: {trimmed}",
|
||||
"project": "", # global/identity, not project-scoped
|
||||
"confidence": 0.5,
|
||||
"status": "candidate",
|
||||
}
|
||||
return api_post(base_url, "/memory", body)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--openclaw-host", default=DEFAULT_OPENCLAW_HOST)
|
||||
parser.add_argument("--openclaw-path", default=DEFAULT_OPENCLAW_PATH)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"openclaw_host={args.openclaw_host} openclaw_path={args.openclaw_path}")
|
||||
print(f"dry_run={args.dry_run}")
|
||||
|
||||
# Check SSH connectivity first
|
||||
test = ssh_cat(args.openclaw_host, f"{args.openclaw_path}/SOUL.md")
|
||||
if test is None:
|
||||
print("ERROR: cannot reach OpenClaw workspace via SSH or SOUL.md not found")
|
||||
print("Check: ssh key installed? path correct? workspace exists?")
|
||||
return 1
|
||||
|
||||
hashes = load_hash_state(args.base_url)
|
||||
imported = skipped = errors = 0
|
||||
|
||||
# 1. Durable files
|
||||
for filename, mem_type in DURABLE_FILES:
|
||||
remote = f"{args.openclaw_path}/{filename}"
|
||||
content = ssh_cat(args.openclaw_host, remote)
|
||||
if content is None or not content.strip():
|
||||
print(f" - {filename}: not found or empty")
|
||||
continue
|
||||
|
||||
h = content_hash(content)
|
||||
if hashes.get(filename) == h:
|
||||
print(f" = {filename}: unchanged (hash {h})")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
print(f" + {filename}: changed (hash {h}, {len(content)}ch)")
|
||||
if not args.dry_run:
|
||||
try:
|
||||
result = import_file_as_memory(
|
||||
args.base_url, filename, content, mem_type,
|
||||
source_tag="openclaw-durable",
|
||||
)
|
||||
if result.get("skipped"):
|
||||
print(f" (duplicate content, skipped)")
|
||||
else:
|
||||
print(f" -> candidate {result.get('id', '?')[:8]}")
|
||||
imported += 1
|
||||
hashes[filename] = h
|
||||
except Exception as e:
|
||||
print(f" ! error: {e}")
|
||||
errors += 1
|
||||
|
||||
# 2. Daily memory logs (memory/YYYY-MM-DD.md)
|
||||
daily_glob = f"{args.openclaw_path}/{DAILY_MEMORY_GLOB}"
|
||||
daily_files = ssh_ls(args.openclaw_host, daily_glob)
|
||||
print(f"\ndaily memory files: {len(daily_files)}")
|
||||
|
||||
# Only process the most recent 7 daily files to avoid flooding
|
||||
for remote_path in sorted(daily_files)[-7:]:
|
||||
filename = Path(remote_path).name
|
||||
content = ssh_cat(args.openclaw_host, remote_path)
|
||||
if content is None or not content.strip():
|
||||
continue
|
||||
|
||||
h = content_hash(content)
|
||||
key = f"daily/{filename}"
|
||||
if hashes.get(key) == h:
|
||||
print(f" = {filename}: unchanged")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
print(f" + {filename}: changed ({len(content)}ch)")
|
||||
if not args.dry_run:
|
||||
try:
|
||||
result = import_file_as_memory(
|
||||
args.base_url, filename, content, "episodic",
|
||||
source_tag="openclaw-daily",
|
||||
)
|
||||
if not result.get("skipped"):
|
||||
print(f" -> candidate {result.get('id', '?')[:8]}")
|
||||
imported += 1
|
||||
hashes[key] = h
|
||||
except Exception as e:
|
||||
print(f" ! error: {e}")
|
||||
errors += 1
|
||||
|
||||
# Save hash state
|
||||
if not args.dry_run and imported > 0:
|
||||
save_hash_state(args.base_url, hashes)
|
||||
|
||||
print(f"\nimported={imported} skipped={skipped} errors={errors}")
|
||||
print("Candidates queued — auto-triage will filter them on next run.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main() or 0)
|
||||
49
scripts/integrity_check.py
Normal file
49
scripts/integrity_check.py
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trigger the integrity check inside the AtoCore container.
|
||||
|
||||
The scan itself lives in the container (needs direct DB access via the
|
||||
already-loaded sqlite connection). This host-side wrapper just POSTs to
|
||||
/admin/integrity-check so the nightly cron can kick it off from bash
|
||||
without needing the container's Python deps on the host.
|
||||
|
||||
Usage:
|
||||
python3 scripts/integrity_check.py [--base-url URL] [--dry-run]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default=os.environ.get("ATOCORE_BASE_URL", "http://127.0.0.1:8100"))
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Report without persisting findings to state")
|
||||
args = parser.parse_args()
|
||||
|
||||
url = args.base_url.rstrip("/") + "/admin/integrity-check"
|
||||
if args.dry_run:
|
||||
url += "?persist=false"
|
||||
|
||||
req = urllib.request.Request(url, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
print(f"ERROR: could not reach {url}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
if not result.get("ok", True):
|
||||
# Non-zero exit so cron logs flag it
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
170
scripts/lint_knowledge_base.py
Normal file
170
scripts/lint_knowledge_base.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Weekly lint pass — health check for the AtoCore knowledge base.
|
||||
|
||||
Inspired by Karpathy's LLM Wiki pattern (the 'lint' operation).
|
||||
Checks for orphans, stale claims, contradictions, and gaps.
|
||||
Outputs a report that can be posted to the wiki as needs_review.
|
||||
|
||||
Usage:
|
||||
python3 scripts/lint_knowledge_base.py --base-url http://dalidou:8100
|
||||
|
||||
Run weekly via cron, or on-demand when the knowledge base feels stale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://localhost:8100")
|
||||
ORPHAN_AGE_DAYS = 14
|
||||
|
||||
|
||||
def api_get(base_url: str, path: str):
|
||||
with urllib.request.urlopen(f"{base_url}{path}", timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def parse_ts(ts: str) -> datetime | None:
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
args = parser.parse_args()
|
||||
b = args.base_url
|
||||
now = datetime.now(timezone.utc)
|
||||
orphan_threshold = now - timedelta(days=ORPHAN_AGE_DAYS)
|
||||
|
||||
print(f"=== AtoCore Lint — {now.strftime('%Y-%m-%d %H:%M UTC')} ===\n")
|
||||
|
||||
findings = {
|
||||
"orphan_memories": [],
|
||||
"stale_candidates": [],
|
||||
"unused_entities": [],
|
||||
"empty_state_projects": [],
|
||||
"unregistered_projects": [],
|
||||
}
|
||||
|
||||
# 1. Orphan memories: active but never reinforced after N days
|
||||
memories = api_get(b, "/memory?active_only=true&limit=500").get("memories", [])
|
||||
for m in memories:
|
||||
updated = parse_ts(m.get("updated_at", ""))
|
||||
if m.get("reference_count", 0) == 0 and updated and updated < orphan_threshold:
|
||||
findings["orphan_memories"].append({
|
||||
"id": m["id"],
|
||||
"type": m["memory_type"],
|
||||
"project": m.get("project") or "(none)",
|
||||
"age_days": (now - updated).days,
|
||||
"content": m["content"][:120],
|
||||
})
|
||||
|
||||
# 2. Stale candidates: been in queue > 7 days without triage
|
||||
candidates = api_get(b, "/memory?status=candidate&limit=500").get("memories", [])
|
||||
stale_threshold = now - timedelta(days=7)
|
||||
for c in candidates:
|
||||
updated = parse_ts(c.get("updated_at", ""))
|
||||
if updated and updated < stale_threshold:
|
||||
findings["stale_candidates"].append({
|
||||
"id": c["id"],
|
||||
"age_days": (now - updated).days,
|
||||
"content": c["content"][:120],
|
||||
})
|
||||
|
||||
# 3. Unused entities: no relationships in either direction
|
||||
entities = api_get(b, "/entities?limit=500").get("entities", [])
|
||||
for e in entities:
|
||||
try:
|
||||
detail = api_get(b, f"/entities/{e['id']}")
|
||||
if not detail.get("relationships"):
|
||||
findings["unused_entities"].append({
|
||||
"id": e["id"],
|
||||
"type": e["entity_type"],
|
||||
"name": e["name"],
|
||||
"project": e.get("project") or "(none)",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. Registered projects with no state entries
|
||||
try:
|
||||
projects = api_get(b, "/projects").get("projects", [])
|
||||
for p in projects:
|
||||
state = api_get(b, f"/project/state/{p['id']}").get("entries", [])
|
||||
if not state:
|
||||
findings["empty_state_projects"].append(p["id"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5. Memories tagged to unregistered projects (auto-detection candidates)
|
||||
registered_ids = {p["id"] for p in projects} | {
|
||||
a for p in projects for a in p.get("aliases", [])
|
||||
}
|
||||
all_mems = api_get(b, "/memory?limit=500").get("memories", [])
|
||||
for m in all_mems:
|
||||
proj = m.get("project", "")
|
||||
if proj and proj not in registered_ids and proj != "(none)":
|
||||
if proj not in findings["unregistered_projects"]:
|
||||
findings["unregistered_projects"].append(proj)
|
||||
|
||||
# Print report
|
||||
print(f"## Orphan memories (active, no reinforcement, >{ORPHAN_AGE_DAYS} days old)")
|
||||
if findings["orphan_memories"]:
|
||||
print(f" Found: {len(findings['orphan_memories'])}")
|
||||
for o in findings["orphan_memories"][:10]:
|
||||
print(f" - [{o['type']}] {o['project']} ({o['age_days']}d): {o['content']}")
|
||||
else:
|
||||
print(" (none)")
|
||||
|
||||
print(f"\n## Stale candidates (>7 days in queue)")
|
||||
if findings["stale_candidates"]:
|
||||
print(f" Found: {len(findings['stale_candidates'])}")
|
||||
for s in findings["stale_candidates"][:10]:
|
||||
print(f" - ({s['age_days']}d): {s['content']}")
|
||||
else:
|
||||
print(" (none)")
|
||||
|
||||
print(f"\n## Unused entities (no relationships)")
|
||||
if findings["unused_entities"]:
|
||||
print(f" Found: {len(findings['unused_entities'])}")
|
||||
for u in findings["unused_entities"][:10]:
|
||||
print(f" - [{u['type']}] {u['project']}: {u['name']}")
|
||||
else:
|
||||
print(" (none)")
|
||||
|
||||
print(f"\n## Empty-state projects")
|
||||
if findings["empty_state_projects"]:
|
||||
print(f" Found: {len(findings['empty_state_projects'])}")
|
||||
for p in findings["empty_state_projects"]:
|
||||
print(f" - {p}")
|
||||
else:
|
||||
print(" (none)")
|
||||
|
||||
print(f"\n## Unregistered projects detected in memories")
|
||||
if findings["unregistered_projects"]:
|
||||
print(f" Found: {len(findings['unregistered_projects'])}")
|
||||
print(" These were auto-detected by extraction — consider registering them:")
|
||||
for p in findings["unregistered_projects"]:
|
||||
print(f" - {p}")
|
||||
else:
|
||||
print(" (none)")
|
||||
|
||||
total_findings = sum(
|
||||
len(v) if isinstance(v, list) else 0 for v in findings.values()
|
||||
)
|
||||
print(f"\n=== Total findings: {total_findings} ===")
|
||||
|
||||
# Return exit code based on findings count (for CI)
|
||||
return 0 if total_findings == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
463
scripts/memory_dedup.py
Normal file
463
scripts/memory_dedup.py
Normal file
@@ -0,0 +1,463 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase 7A — semantic memory dedup detector.
|
||||
|
||||
Finds clusters of near-duplicate active memories and writes merge-
|
||||
candidate proposals for human review in the triage UI.
|
||||
|
||||
Algorithm:
|
||||
1. Fetch active memories via HTTP
|
||||
2. Group by (project, memory_type) — cross-bucket merges are deferred
|
||||
to Phase 7B contradiction flow
|
||||
3. Within each group, embed contents via atocore.retrieval.embeddings
|
||||
4. Greedy transitive cluster at similarity >= threshold
|
||||
5. For each cluster of size >= 2, ask claude-p to draft unified content
|
||||
6. POST the proposal to /admin/memory/merge-candidates/create (server-
|
||||
side dedupes by the sorted memory-id set, so re-runs don't double-
|
||||
create)
|
||||
|
||||
Host-side because claude CLI lives on Dalidou, not the container. Reuses
|
||||
the same PYTHONPATH=src pattern as scripts/graduate_memories.py for
|
||||
atocore imports (embeddings, similarity, prompt module).
|
||||
|
||||
Usage:
|
||||
python3 scripts/memory_dedup.py --base-url http://127.0.0.1:8100 \\
|
||||
--similarity-threshold 0.88 --max-batch 50
|
||||
|
||||
Threshold conventions (see Phase 7 doc):
|
||||
0.88 interactive / default — balanced precision/recall
|
||||
0.90 nightly cron — tight, only near-duplicates
|
||||
0.85 weekly cron — deeper cleanup
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
# Make src/ importable — same pattern as graduate_memories.py
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_SRC_DIR = os.path.abspath(os.path.join(_SCRIPT_DIR, "..", "src"))
|
||||
if _SRC_DIR not in sys.path:
|
||||
sys.path.insert(0, _SRC_DIR)
|
||||
|
||||
from atocore.memory._dedup_prompt import ( # noqa: E402
|
||||
DEDUP_PROMPT_VERSION,
|
||||
SYSTEM_PROMPT,
|
||||
TIER2_SYSTEM_PROMPT,
|
||||
build_tier2_user_message,
|
||||
build_user_message,
|
||||
normalize_merge_verdict,
|
||||
parse_merge_verdict,
|
||||
)
|
||||
from atocore.memory.similarity import cluster_by_threshold # noqa: E402
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://127.0.0.1:8100")
|
||||
DEFAULT_MODEL = os.environ.get("ATOCORE_DEDUP_MODEL", "sonnet")
|
||||
DEFAULT_TIER2_MODEL = os.environ.get("ATOCORE_DEDUP_TIER2_MODEL", "opus")
|
||||
DEFAULT_TIMEOUT_S = float(os.environ.get("ATOCORE_DEDUP_TIMEOUT_S", "60"))
|
||||
|
||||
# Phase 7A.1 — auto-merge tiering thresholds.
|
||||
# TIER-1 auto-approve: if sonnet confidence >= this AND min pairwise
|
||||
# similarity >= AUTO_APPROVE_SIM AND all sources share project+type → merge silently.
|
||||
AUTO_APPROVE_CONF = float(os.environ.get("ATOCORE_DEDUP_AUTO_APPROVE_CONF", "0.8"))
|
||||
AUTO_APPROVE_SIM = float(os.environ.get("ATOCORE_DEDUP_AUTO_APPROVE_SIM", "0.92"))
|
||||
# TIER-2 escalation band: sonnet uncertain but pair is similar enough to be worth opus time.
|
||||
TIER2_MIN_CONF = float(os.environ.get("ATOCORE_DEDUP_TIER2_MIN_CONF", "0.5"))
|
||||
TIER2_MIN_SIM = float(os.environ.get("ATOCORE_DEDUP_TIER2_MIN_SIM", "0.85"))
|
||||
|
||||
_sandbox_cwd = None
|
||||
|
||||
|
||||
def get_sandbox_cwd() -> str:
|
||||
global _sandbox_cwd
|
||||
if _sandbox_cwd is None:
|
||||
_sandbox_cwd = tempfile.mkdtemp(prefix="ato-dedup-")
|
||||
return _sandbox_cwd
|
||||
|
||||
|
||||
def api_get(base_url: str, path: str) -> dict:
|
||||
req = urllib.request.Request(f"{base_url}{path}")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def api_post(base_url: str, path: str, body: dict | None = None) -> dict:
|
||||
data = json.dumps(body or {}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def call_claude(system_prompt: str, user_message: str, model: str, timeout_s: float) -> tuple[str | None, str | None]:
|
||||
"""Shared CLI caller with retry + stderr capture (mirrors auto_triage)."""
|
||||
if not shutil.which("claude"):
|
||||
return None, "claude CLI not available"
|
||||
args = [
|
||||
"claude", "-p",
|
||||
"--model", model,
|
||||
"--append-system-prompt", system_prompt,
|
||||
"--disable-slash-commands",
|
||||
user_message,
|
||||
]
|
||||
last_error = ""
|
||||
for attempt in range(3):
|
||||
if attempt > 0:
|
||||
time.sleep(2 ** attempt)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
args, capture_output=True, text=True,
|
||||
timeout=timeout_s, cwd=get_sandbox_cwd(),
|
||||
encoding="utf-8", errors="replace",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
last_error = f"{model} timed out"
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_error = f"subprocess error: {exc}"
|
||||
continue
|
||||
if completed.returncode == 0:
|
||||
return (completed.stdout or "").strip(), None
|
||||
stderr = (completed.stderr or "").strip()[:200]
|
||||
last_error = f"{model} exit {completed.returncode}: {stderr}" if stderr else f"{model} exit {completed.returncode}"
|
||||
return None, last_error
|
||||
|
||||
|
||||
def fetch_active_memories(base_url: str, project: str | None) -> list[dict]:
|
||||
# The /memory endpoint with active_only=true returns active memories.
|
||||
# Graduated memories are exempt from dedup — they're frozen pointers
|
||||
# to entities. Filter them out on the client side.
|
||||
params = "active_only=true&limit=2000"
|
||||
if project:
|
||||
params += f"&project={urllib.request.quote(project)}"
|
||||
try:
|
||||
result = api_get(base_url, f"/memory?{params}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: could not fetch memories: {e}", file=sys.stderr)
|
||||
return []
|
||||
mems = result.get("memories", [])
|
||||
return [m for m in mems if (m.get("status") or "active") == "active"]
|
||||
|
||||
|
||||
def group_memories(mems: list[dict]) -> dict[tuple[str, str], list[dict]]:
|
||||
"""Bucket by (project, memory_type). Empty project is its own bucket."""
|
||||
buckets: dict[tuple[str, str], list[dict]] = defaultdict(list)
|
||||
for m in mems:
|
||||
key = ((m.get("project") or "").strip().lower(), (m.get("memory_type") or "").strip().lower())
|
||||
buckets[key].append(m)
|
||||
return buckets
|
||||
|
||||
|
||||
def draft_merge(sources: list[dict], model: str, timeout_s: float) -> dict[str, Any] | None:
|
||||
"""Tier-1 draft: cheap sonnet call proposes the unified content."""
|
||||
user_msg = build_user_message(sources)
|
||||
raw, err = call_claude(SYSTEM_PROMPT, user_msg, model, timeout_s)
|
||||
if err:
|
||||
print(f" WARN: claude tier-1 failed: {err}", file=sys.stderr)
|
||||
return None
|
||||
parsed = parse_merge_verdict(raw or "")
|
||||
if parsed is None:
|
||||
print(f" WARN: could not parse tier-1 verdict: {(raw or '')[:200]}", file=sys.stderr)
|
||||
return None
|
||||
return normalize_merge_verdict(parsed)
|
||||
|
||||
|
||||
def tier2_review(
|
||||
sources: list[dict],
|
||||
tier1_verdict: dict[str, Any],
|
||||
model: str,
|
||||
timeout_s: float,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Tier-2 second opinion: opus confirms or overrides the tier-1 draft."""
|
||||
user_msg = build_tier2_user_message(sources, tier1_verdict)
|
||||
raw, err = call_claude(TIER2_SYSTEM_PROMPT, user_msg, model, timeout_s)
|
||||
if err:
|
||||
print(f" WARN: claude tier-2 failed: {err}", file=sys.stderr)
|
||||
return None
|
||||
parsed = parse_merge_verdict(raw or "")
|
||||
if parsed is None:
|
||||
print(f" WARN: could not parse tier-2 verdict: {(raw or '')[:200]}", file=sys.stderr)
|
||||
return None
|
||||
return normalize_merge_verdict(parsed)
|
||||
|
||||
|
||||
def min_pairwise_similarity(texts: list[str]) -> float:
|
||||
"""Return the minimum pairwise cosine similarity across N texts.
|
||||
|
||||
Used to sanity-check a transitive cluster: A~B~C doesn't guarantee
|
||||
A~C, so the auto-approve threshold should be met by the WEAKEST
|
||||
pair, not just by the strongest. If the cluster has N=2 this is just
|
||||
the one pairwise similarity.
|
||||
"""
|
||||
if len(texts) < 2:
|
||||
return 0.0
|
||||
# Reuse similarity_matrix rather than computing it ourselves
|
||||
from atocore.memory.similarity import similarity_matrix
|
||||
m = similarity_matrix(texts)
|
||||
min_sim = 1.0
|
||||
for i in range(len(texts)):
|
||||
for j in range(i + 1, len(texts)):
|
||||
if m[i][j] < min_sim:
|
||||
min_sim = m[i][j]
|
||||
return min_sim
|
||||
|
||||
|
||||
def submit_candidate(
|
||||
base_url: str,
|
||||
memory_ids: list[str],
|
||||
similarity: float,
|
||||
verdict: dict[str, Any],
|
||||
dry_run: bool,
|
||||
) -> str | None:
|
||||
body = {
|
||||
"memory_ids": memory_ids,
|
||||
"similarity": similarity,
|
||||
"proposed_content": verdict["content"],
|
||||
"proposed_memory_type": verdict["memory_type"],
|
||||
"proposed_project": verdict["project"],
|
||||
"proposed_tags": verdict["domain_tags"],
|
||||
"proposed_confidence": verdict["confidence"],
|
||||
"reason": verdict["reason"],
|
||||
}
|
||||
if dry_run:
|
||||
print(f" [dry-run] would POST: {json.dumps(body)[:200]}...")
|
||||
return "dry-run"
|
||||
try:
|
||||
result = api_post(base_url, "/admin/memory/merge-candidates/create", body)
|
||||
return result.get("candidate_id")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f" ERROR: submit failed: {e.code} {e.read().decode()[:200]}", file=sys.stderr)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f" ERROR: submit failed: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def auto_approve(base_url: str, candidate_id: str, actor: str, dry_run: bool) -> str | None:
|
||||
"""POST /admin/memory/merge-candidates/{id}/approve. Returns result_memory_id."""
|
||||
if dry_run:
|
||||
return "dry-run"
|
||||
try:
|
||||
result = api_post(
|
||||
base_url,
|
||||
f"/admin/memory/merge-candidates/{candidate_id}/approve",
|
||||
{"actor": actor},
|
||||
)
|
||||
return result.get("result_memory_id")
|
||||
except Exception as e:
|
||||
print(f" ERROR: auto-approve failed: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def same_bucket(sources: list[dict]) -> bool:
|
||||
"""All sources share the same (project, memory_type)."""
|
||||
if not sources:
|
||||
return False
|
||||
proj = (sources[0].get("project") or "").strip().lower()
|
||||
mtype = (sources[0].get("memory_type") or "").strip().lower()
|
||||
for s in sources[1:]:
|
||||
if (s.get("project") or "").strip().lower() != proj:
|
||||
return False
|
||||
if (s.get("memory_type") or "").strip().lower() != mtype:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Phase 7A semantic dedup detector (tiered)")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--project", default="", help="Only scan this project (empty = all)")
|
||||
parser.add_argument("--similarity-threshold", type=float, default=0.88)
|
||||
parser.add_argument("--max-batch", type=int, default=50,
|
||||
help="Max clusters to process per run")
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help="Tier-1 model (default: sonnet)")
|
||||
parser.add_argument("--tier2-model", default=DEFAULT_TIER2_MODEL, help="Tier-2 model (default: opus)")
|
||||
parser.add_argument("--timeout-s", type=float, default=DEFAULT_TIMEOUT_S)
|
||||
parser.add_argument("--no-auto-approve", action="store_true",
|
||||
help="Disable autonomous merging; all merges land in human triage queue")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.base_url.rstrip("/")
|
||||
autonomous = not args.no_auto_approve
|
||||
|
||||
print(
|
||||
f"memory_dedup {DEDUP_PROMPT_VERSION} | threshold={args.similarity_threshold} | "
|
||||
f"tier1={args.model} tier2={args.tier2_model} | "
|
||||
f"autonomous={autonomous} | "
|
||||
f"auto-approve: conf>={AUTO_APPROVE_CONF} sim>={AUTO_APPROVE_SIM}"
|
||||
)
|
||||
mems = fetch_active_memories(base, args.project or None)
|
||||
print(f"fetched {len(mems)} active memories")
|
||||
if not mems:
|
||||
return
|
||||
|
||||
buckets = group_memories(mems)
|
||||
print(f"grouped into {len(buckets)} (project, memory_type) buckets")
|
||||
|
||||
clusters_found = 0
|
||||
auto_merged_tier1 = 0
|
||||
auto_merged_tier2 = 0
|
||||
human_candidates = 0
|
||||
tier1_rejections = 0
|
||||
tier2_overrides = 0 # opus disagreed with sonnet
|
||||
skipped_low_sim = 0
|
||||
skipped_existing = 0
|
||||
processed = 0
|
||||
|
||||
for (proj, mtype), group in sorted(buckets.items()):
|
||||
if len(group) < 2:
|
||||
continue
|
||||
if processed >= args.max_batch:
|
||||
print(f"reached max-batch={args.max_batch}, stopping")
|
||||
break
|
||||
|
||||
texts = [(m.get("content") or "") for m in group]
|
||||
clusters = cluster_by_threshold(texts, args.similarity_threshold)
|
||||
clusters = [c for c in clusters if len(c) >= 2]
|
||||
if not clusters:
|
||||
continue
|
||||
|
||||
print(f"\n[{proj or '(global)'}/{mtype}] {len(group)} mems → {len(clusters)} cluster(s)")
|
||||
for cluster in clusters:
|
||||
if processed >= args.max_batch:
|
||||
break
|
||||
clusters_found += 1
|
||||
sources = [group[i] for i in cluster]
|
||||
ids = [s["id"] for s in sources]
|
||||
cluster_texts = [texts[i] for i in cluster]
|
||||
min_sim = min_pairwise_similarity(cluster_texts)
|
||||
print(f" cluster of {len(cluster)} (min_sim={min_sim:.3f}): {[s['id'][:8] for s in sources]}")
|
||||
|
||||
# Tier-1 draft
|
||||
tier1 = draft_merge(sources, args.model, args.timeout_s)
|
||||
processed += 1
|
||||
if tier1 is None:
|
||||
continue
|
||||
if tier1["action"] == "reject":
|
||||
tier1_rejections += 1
|
||||
print(f" TIER-1 rejected: {tier1['reason'][:100]}")
|
||||
continue
|
||||
|
||||
# --- Tiering decision ---
|
||||
bucket_ok = same_bucket(sources)
|
||||
tier1_ok = (
|
||||
tier1["confidence"] >= AUTO_APPROVE_CONF
|
||||
and min_sim >= AUTO_APPROVE_SIM
|
||||
and bucket_ok
|
||||
)
|
||||
|
||||
if autonomous and tier1_ok:
|
||||
cid = submit_candidate(base, ids, min_sim, tier1, args.dry_run)
|
||||
if cid == "dry-run":
|
||||
auto_merged_tier1 += 1
|
||||
print(" [dry-run] would auto-merge (tier-1)")
|
||||
elif cid:
|
||||
new_id = auto_approve(base, cid, actor="auto-dedup-tier1", dry_run=args.dry_run)
|
||||
if new_id:
|
||||
auto_merged_tier1 += 1
|
||||
print(f" ✅ auto-merged (tier-1) → {str(new_id)[:8]}")
|
||||
else:
|
||||
print(f" ⚠️ tier-1 approve failed; candidate {cid[:8]} left pending")
|
||||
human_candidates += 1
|
||||
else:
|
||||
skipped_existing += 1
|
||||
time.sleep(0.3)
|
||||
continue
|
||||
|
||||
# Not tier-1 auto-approve. Decide if it's worth tier-2 escalation.
|
||||
tier2_eligible = (
|
||||
autonomous
|
||||
and min_sim >= TIER2_MIN_SIM
|
||||
and tier1["confidence"] >= TIER2_MIN_CONF
|
||||
and bucket_ok
|
||||
)
|
||||
|
||||
if tier2_eligible:
|
||||
print(" → escalating to tier-2 (opus)…")
|
||||
tier2 = tier2_review(sources, tier1, args.tier2_model, args.timeout_s)
|
||||
if tier2 is None:
|
||||
# Opus errored. Fall back to human triage.
|
||||
cid = submit_candidate(base, ids, min_sim, tier1, args.dry_run)
|
||||
if cid and cid != "dry-run":
|
||||
human_candidates += 1
|
||||
print(f" → candidate {cid[:8]} (tier-2 errored, human review)")
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
||||
if tier2["action"] == "reject":
|
||||
tier2_overrides += 1
|
||||
print(f" ❌ TIER-2 override (reject): {tier2['reason'][:100]}")
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
||||
if tier2["confidence"] >= AUTO_APPROVE_CONF:
|
||||
# Opus confirms. Auto-merge using opus's (possibly refined) content.
|
||||
cid = submit_candidate(base, ids, min_sim, tier2, args.dry_run)
|
||||
if cid == "dry-run":
|
||||
auto_merged_tier2 += 1
|
||||
print(" [dry-run] would auto-merge (tier-2)")
|
||||
elif cid:
|
||||
new_id = auto_approve(base, cid, actor="auto-dedup-tier2", dry_run=args.dry_run)
|
||||
if new_id:
|
||||
auto_merged_tier2 += 1
|
||||
print(f" ✅ auto-merged (tier-2) → {str(new_id)[:8]}")
|
||||
else:
|
||||
human_candidates += 1
|
||||
print(f" ⚠️ tier-2 approve failed; candidate {cid[:8]} left pending")
|
||||
else:
|
||||
skipped_existing += 1
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
||||
# Opus confirmed but low confidence → human review with opus's draft
|
||||
cid = submit_candidate(base, ids, min_sim, tier2, args.dry_run)
|
||||
if cid and cid != "dry-run":
|
||||
human_candidates += 1
|
||||
print(f" → candidate {cid[:8]} (tier-2 low-confidence, human review)")
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
||||
# Below tier-2 eligibility (either non-autonomous mode, or
|
||||
# similarity too low / cross-bucket). Always human review.
|
||||
if min_sim < TIER2_MIN_SIM or not bucket_ok:
|
||||
skipped_low_sim += 1
|
||||
# Still create a human candidate so it's visible, but log why
|
||||
print(f" → below auto-tier thresholds (min_sim={min_sim:.3f}, bucket_ok={bucket_ok})")
|
||||
|
||||
cid = submit_candidate(base, ids, min_sim, tier1, args.dry_run)
|
||||
if cid == "dry-run":
|
||||
human_candidates += 1
|
||||
elif cid:
|
||||
human_candidates += 1
|
||||
print(f" → candidate {cid[:8]} (human review)")
|
||||
else:
|
||||
skipped_existing += 1
|
||||
time.sleep(0.3)
|
||||
|
||||
print(
|
||||
f"\nsummary: clusters_found={clusters_found} "
|
||||
f"auto_merged_tier1={auto_merged_tier1} "
|
||||
f"auto_merged_tier2={auto_merged_tier2} "
|
||||
f"human_candidates={human_candidates} "
|
||||
f"tier1_rejections={tier1_rejections} "
|
||||
f"tier2_overrides={tier2_overrides} "
|
||||
f"skipped_low_sim={skipped_low_sim} "
|
||||
f"skipped_existing={skipped_existing}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1018
scripts/migrate_legacy_aliases.py
Normal file
1018
scripts/migrate_legacy_aliases.py
Normal file
File diff suppressed because it is too large
Load Diff
89
scripts/persist_llm_candidates.py
Normal file
89
scripts/persist_llm_candidates.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Persist LLM-extracted candidates from a baseline JSON to Dalidou.
|
||||
|
||||
One-shot script: reads a saved extractor eval output file, filters to
|
||||
candidates the LLM actually produced, and POSTs each to the Dalidou
|
||||
memory API with ``status=candidate``. Deduplicates against already-
|
||||
existing candidate content so the script is safe to re-run.
|
||||
|
||||
Usage:
|
||||
|
||||
python scripts/persist_llm_candidates.py \\
|
||||
scripts/eval_data/extractor_llm_baseline_2026-04-11.json
|
||||
|
||||
Then triage via:
|
||||
|
||||
python scripts/atocore_client.py triage
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://dalidou:8100")
|
||||
TIMEOUT = int(os.environ.get("ATOCORE_TIMEOUT_SECONDS", "10"))
|
||||
|
||||
|
||||
def post_json(path: str, body: dict) -> dict:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url=f"{BASE_URL}{path}",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=data,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print(f"usage: {sys.argv[0]} <baseline_json>", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
data = json.loads(open(sys.argv[1], encoding="utf-8").read())
|
||||
results = data.get("results", [])
|
||||
|
||||
persisted = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
for r in results:
|
||||
for c in r.get("actual_candidates", []):
|
||||
content = (c.get("content") or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
mem_type = c.get("memory_type", "knowledge")
|
||||
project = c.get("project", "")
|
||||
confidence = c.get("confidence", 0.5)
|
||||
|
||||
try:
|
||||
resp = post_json("/memory", {
|
||||
"memory_type": mem_type,
|
||||
"content": content,
|
||||
"project": project,
|
||||
"confidence": float(confidence),
|
||||
"status": "candidate",
|
||||
})
|
||||
persisted += 1
|
||||
print(f" + {resp.get('id','?')[:8]} [{mem_type}] {content[:80]}")
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 400:
|
||||
skipped += 1
|
||||
else:
|
||||
errors += 1
|
||||
print(f" ! error {exc.code}: {content[:60]}", file=sys.stderr)
|
||||
except Exception as exc:
|
||||
errors += 1
|
||||
print(f" ! {exc}: {content[:60]}", file=sys.stderr)
|
||||
|
||||
print(f"\npersisted={persisted} skipped={skipped} errors={errors}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
194
scripts/retrieval_eval.py
Normal file
194
scripts/retrieval_eval.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Retrieval quality eval harness.
|
||||
|
||||
Runs a fixed set of project-hinted questions against
|
||||
``POST /context/build`` on a live AtoCore instance and scores the
|
||||
resulting ``formatted_context`` against per-question expectations.
|
||||
The goal is a diffable scorecard that tells you, run-to-run,
|
||||
whether a retrieval / builder / ingestion change moved the needle.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- Fixtures live in ``scripts/retrieval_eval_fixtures.json`` so new
|
||||
questions can be added without touching Python. Each fixture
|
||||
names the project, the prompt, and a checklist of substrings that
|
||||
MUST appear in ``formatted_context`` (``expect_present``) and
|
||||
substrings that MUST NOT appear (``expect_absent``). The absent
|
||||
list catches cross-project bleed and stale content.
|
||||
- The checklist is deliberately substring-based (not regex, not
|
||||
embedding-similarity) so a failure is always a trivially
|
||||
reproducible "this string is not in that string". Richer scoring
|
||||
can come later once we know the harness is useful.
|
||||
- The harness is external to the app runtime and talks to AtoCore
|
||||
over HTTP, so it works against dev, staging, or prod. It follows
|
||||
the same environment-variable contract as ``atocore_client.py``
|
||||
(``ATOCORE_BASE_URL``, ``ATOCORE_TIMEOUT_SECONDS``).
|
||||
- Exit code 0 on all-pass, 1 on any fixture failure. Intended for
|
||||
manual runs today; a future cron / CI hook can consume the
|
||||
JSON output via ``--json``.
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
python scripts/retrieval_eval.py # human-readable report
|
||||
python scripts/retrieval_eval.py --json # machine-readable
|
||||
python scripts/retrieval_eval.py --fixtures path/to/custom.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://dalidou:8100")
|
||||
DEFAULT_TIMEOUT = int(os.environ.get("ATOCORE_TIMEOUT_SECONDS", "30"))
|
||||
DEFAULT_BUDGET = 3000
|
||||
DEFAULT_FIXTURES = Path(__file__).parent / "retrieval_eval_fixtures.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fixture:
|
||||
name: str
|
||||
project: str
|
||||
prompt: str
|
||||
budget: int = DEFAULT_BUDGET
|
||||
expect_present: list[str] = field(default_factory=list)
|
||||
expect_absent: list[str] = field(default_factory=list)
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FixtureResult:
|
||||
fixture: Fixture
|
||||
ok: bool
|
||||
missing_present: list[str]
|
||||
unexpected_absent: list[str]
|
||||
total_chars: int
|
||||
error: str = ""
|
||||
|
||||
|
||||
def load_fixtures(path: Path) -> list[Fixture]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, list):
|
||||
raise ValueError(f"{path} must contain a JSON array of fixtures")
|
||||
fixtures: list[Fixture] = []
|
||||
for i, raw in enumerate(data):
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"fixture {i} is not an object")
|
||||
fixtures.append(
|
||||
Fixture(
|
||||
name=raw["name"],
|
||||
project=raw.get("project", ""),
|
||||
prompt=raw["prompt"],
|
||||
budget=int(raw.get("budget", DEFAULT_BUDGET)),
|
||||
expect_present=list(raw.get("expect_present", [])),
|
||||
expect_absent=list(raw.get("expect_absent", [])),
|
||||
notes=raw.get("notes", ""),
|
||||
)
|
||||
)
|
||||
return fixtures
|
||||
|
||||
|
||||
def run_fixture(fixture: Fixture, base_url: str, timeout: int) -> FixtureResult:
|
||||
payload = {
|
||||
"prompt": fixture.prompt,
|
||||
"project": fixture.project or None,
|
||||
"budget": fixture.budget,
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
url=f"{base_url}/context/build",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.URLError as exc:
|
||||
return FixtureResult(
|
||||
fixture=fixture,
|
||||
ok=False,
|
||||
missing_present=list(fixture.expect_present),
|
||||
unexpected_absent=[],
|
||||
total_chars=0,
|
||||
error=f"http_error: {exc}",
|
||||
)
|
||||
|
||||
formatted = body.get("formatted_context") or ""
|
||||
missing = [s for s in fixture.expect_present if s not in formatted]
|
||||
unexpected = [s for s in fixture.expect_absent if s in formatted]
|
||||
return FixtureResult(
|
||||
fixture=fixture,
|
||||
ok=not missing and not unexpected,
|
||||
missing_present=missing,
|
||||
unexpected_absent=unexpected,
|
||||
total_chars=len(formatted),
|
||||
)
|
||||
|
||||
|
||||
def print_human_report(results: list[FixtureResult]) -> None:
|
||||
total = len(results)
|
||||
passed = sum(1 for r in results if r.ok)
|
||||
print(f"Retrieval eval: {passed}/{total} fixtures passed")
|
||||
print()
|
||||
for r in results:
|
||||
marker = "PASS" if r.ok else "FAIL"
|
||||
print(f"[{marker}] {r.fixture.name} project={r.fixture.project} chars={r.total_chars}")
|
||||
if r.error:
|
||||
print(f" error: {r.error}")
|
||||
for miss in r.missing_present:
|
||||
print(f" missing expected: {miss!r}")
|
||||
for bleed in r.unexpected_absent:
|
||||
print(f" unexpected present: {bleed!r}")
|
||||
if r.fixture.notes and not r.ok:
|
||||
print(f" notes: {r.fixture.notes}")
|
||||
|
||||
|
||||
def print_json_report(results: list[FixtureResult]) -> None:
|
||||
payload = {
|
||||
"total": len(results),
|
||||
"passed": sum(1 for r in results if r.ok),
|
||||
"fixtures": [
|
||||
{
|
||||
"name": r.fixture.name,
|
||||
"project": r.fixture.project,
|
||||
"ok": r.ok,
|
||||
"total_chars": r.total_chars,
|
||||
"missing_present": r.missing_present,
|
||||
"unexpected_absent": r.unexpected_absent,
|
||||
"error": r.error,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
json.dump(payload, sys.stdout, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="AtoCore retrieval quality eval harness")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
|
||||
parser.add_argument("--fixtures", type=Path, default=DEFAULT_FIXTURES)
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
fixtures = load_fixtures(args.fixtures)
|
||||
results = [run_fixture(f, args.base_url, args.timeout) for f in fixtures]
|
||||
|
||||
if args.json:
|
||||
print_json_report(results)
|
||||
else:
|
||||
print_human_report(results)
|
||||
|
||||
return 0 if all(r.ok for r in results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
225
scripts/retrieval_eval_fixtures.json
Normal file
225
scripts/retrieval_eval_fixtures.json
Normal file
@@ -0,0 +1,225 @@
|
||||
[
|
||||
{
|
||||
"name": "p04-architecture-decision",
|
||||
"project": "p04-gigabit",
|
||||
"prompt": "what mirror architecture was selected for GigaBIT M1 and why",
|
||||
"expect_present": [
|
||||
"--- Trusted Project State ---",
|
||||
"Option B",
|
||||
"conical",
|
||||
"--- Project Memories ---"
|
||||
],
|
||||
"expect_absent": [
|
||||
"p06-polisher",
|
||||
"folded-beam"
|
||||
],
|
||||
"notes": "Canonical p04 decision — should surface both Trusted Project State and the project-memory band"
|
||||
},
|
||||
{
|
||||
"name": "p04-constraints",
|
||||
"project": "p04-gigabit",
|
||||
"prompt": "what are the key GigaBIT M1 program constraints",
|
||||
"expect_present": [
|
||||
"--- Trusted Project State ---",
|
||||
"Zerodur",
|
||||
"1.2"
|
||||
],
|
||||
"expect_absent": [
|
||||
"polisher suite"
|
||||
],
|
||||
"notes": "Key constraints are in Trusted Project State and in the mission-framing memory"
|
||||
},
|
||||
{
|
||||
"name": "p04-short-ambiguous",
|
||||
"project": "p04-gigabit",
|
||||
"prompt": "current status",
|
||||
"expect_present": [
|
||||
"--- Trusted Project State ---"
|
||||
],
|
||||
"expect_absent": [],
|
||||
"notes": "Short ambiguous prompt — at minimum project state should surface. Hard case: the prompt is generic enough that chunks may not rank well."
|
||||
},
|
||||
{
|
||||
"name": "p05-configuration",
|
||||
"project": "p05-interferometer",
|
||||
"prompt": "what is the selected interferometer configuration",
|
||||
"expect_present": [
|
||||
"folded-beam",
|
||||
"CGH"
|
||||
],
|
||||
"expect_absent": [
|
||||
"Option B",
|
||||
"conical back",
|
||||
"polisher suite"
|
||||
],
|
||||
"notes": "P05 architecture memory covers folded-beam + CGH. GigaBIT M1 legitimately appears in p05 source docs."
|
||||
},
|
||||
{
|
||||
"name": "p05-vendor-signal",
|
||||
"project": "p05-interferometer",
|
||||
"prompt": "what is the current vendor signal for the interferometer procurement",
|
||||
"expect_present": [
|
||||
"4D",
|
||||
"Zygo"
|
||||
],
|
||||
"expect_absent": [
|
||||
"polisher"
|
||||
],
|
||||
"notes": "Vendor memory mentions 4D as strongest technical candidate and Zygo Verifire SV as value path"
|
||||
},
|
||||
{
|
||||
"name": "p05-cgh-calibration",
|
||||
"project": "p05-interferometer",
|
||||
"prompt": "how does CGH calibration work for the interferometer",
|
||||
"expect_present": [
|
||||
"CGH"
|
||||
],
|
||||
"expect_absent": [
|
||||
"polisher-sim",
|
||||
"polisher-post"
|
||||
],
|
||||
"notes": "CGH is a core p05 concept. Should surface via chunks and possibly the architecture memory. Must not bleed p06 polisher-suite terms."
|
||||
},
|
||||
{
|
||||
"name": "p06-suite-split",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "how is the polisher software suite split across layers",
|
||||
"expect_present": [
|
||||
"polisher-sim",
|
||||
"polisher-post",
|
||||
"polisher-control"
|
||||
],
|
||||
"expect_absent": [
|
||||
"GigaBIT"
|
||||
],
|
||||
"notes": "The three-layer split is in multiple p06 memories"
|
||||
},
|
||||
{
|
||||
"name": "p06-control-rule",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "what is the polisher control design rule",
|
||||
"expect_present": [
|
||||
"interlocks"
|
||||
],
|
||||
"expect_absent": [
|
||||
"interferometer"
|
||||
],
|
||||
"notes": "Control design rule memory mentions interlocks and state transitions"
|
||||
},
|
||||
{
|
||||
"name": "p06-firmware-interface",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "what is the firmware interface contract for the polisher machine",
|
||||
"expect_present": [
|
||||
"controller-job"
|
||||
],
|
||||
"expect_absent": [
|
||||
"interferometer",
|
||||
"GigaBIT"
|
||||
],
|
||||
"notes": "New p06 memory from the first triage: firmware interface contract is invariant controller-job.v1 in, run-log.v1 out"
|
||||
},
|
||||
{
|
||||
"name": "p06-z-axis",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "how does the polisher Z-axis work",
|
||||
"expect_present": [
|
||||
"engage"
|
||||
],
|
||||
"expect_absent": [
|
||||
"interferometer"
|
||||
],
|
||||
"notes": "New p06 memory: Z-axis is binary engage/retract, not continuous position. The word 'engage' should appear."
|
||||
},
|
||||
{
|
||||
"name": "p06-cam-mechanism",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "how is cam amplitude controlled on the polisher",
|
||||
"expect_present": [
|
||||
"encoder"
|
||||
],
|
||||
"expect_absent": [
|
||||
"GigaBIT"
|
||||
],
|
||||
"notes": "New p06 memory: cam set mechanically by operator, read by encoders. The word 'encoder' should appear."
|
||||
},
|
||||
{
|
||||
"name": "p06-telemetry-rate",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "what is the expected polishing telemetry data rate",
|
||||
"expect_present": [
|
||||
"29 MB"
|
||||
],
|
||||
"expect_absent": [
|
||||
"interferometer"
|
||||
],
|
||||
"notes": "New p06 knowledge memory: approximately 29 MB per hour at 100 Hz"
|
||||
},
|
||||
{
|
||||
"name": "p06-offline-design",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "does the polisher machine need network to operate",
|
||||
"expect_present": [
|
||||
"offline"
|
||||
],
|
||||
"expect_absent": [
|
||||
"CGH"
|
||||
],
|
||||
"notes": "New p06 memory: machine works fully offline and independently; network is for remote access only"
|
||||
},
|
||||
{
|
||||
"name": "p06-short-ambiguous",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "current status",
|
||||
"expect_present": [
|
||||
"--- Trusted Project State ---"
|
||||
],
|
||||
"expect_absent": [],
|
||||
"notes": "Short ambiguous prompt — project state should surface at minimum"
|
||||
},
|
||||
{
|
||||
"name": "cross-project-no-bleed",
|
||||
"project": "p04-gigabit",
|
||||
"prompt": "what telemetry rate should we target",
|
||||
"expect_present": [],
|
||||
"expect_absent": [
|
||||
"29 MB",
|
||||
"polisher"
|
||||
],
|
||||
"notes": "Adversarial: telemetry rate is a p06 fact. A p04 query for 'telemetry rate' must NOT surface p06 memories. Tests cross-project gating."
|
||||
},
|
||||
{
|
||||
"name": "no-project-hint",
|
||||
"project": "",
|
||||
"prompt": "tell me about the current projects",
|
||||
"expect_present": [],
|
||||
"expect_absent": [
|
||||
"--- Project Memories ---"
|
||||
],
|
||||
"notes": "Without a project hint, project memories must not appear (cross-project bleed guard). Chunks may appear if any match."
|
||||
},
|
||||
{
|
||||
"name": "p06-usb-ssd",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "what storage solution is specified for the polisher RPi",
|
||||
"expect_present": [
|
||||
"USB SSD"
|
||||
],
|
||||
"expect_absent": [
|
||||
"interferometer"
|
||||
],
|
||||
"notes": "New p06 memory from triage: USB SSD mandatory, not SD card"
|
||||
},
|
||||
{
|
||||
"name": "p06-tailscale",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "how do we access the polisher machine remotely",
|
||||
"expect_present": [
|
||||
"Tailscale"
|
||||
],
|
||||
"expect_absent": [
|
||||
"[Source: p04-gigabit/"
|
||||
],
|
||||
"notes": "New p06 memory: Tailscale mesh for RPi remote access. Cross-project guard is a source-path check, not a word blacklist: the polisher ARCHITECTURE.md legitimately mentions the GigaBIT M1 mirror (it is what the polisher is built for), so testing for absence of that word produces false positives. The real invariant is that no p04 source chunks are retrieved into p06 context."
|
||||
}
|
||||
]
|
||||
159
scripts/seed_project_state.py
Normal file
159
scripts/seed_project_state.py
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed Trusted Project State entries for all active projects.
|
||||
|
||||
Populates the project_state table with curated decisions, requirements,
|
||||
facts, contacts, and milestones so context packs have real content
|
||||
in the highest-trust tier.
|
||||
|
||||
Usage:
|
||||
python3 scripts/seed_project_state.py --base-url http://dalidou:8100
|
||||
python3 scripts/seed_project_state.py --base-url http://dalidou:8100 --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import urllib.request
|
||||
import sys
|
||||
|
||||
# Each entry: (project, category, key, value, source)
|
||||
SEED_ENTRIES: list[tuple[str, str, str, str, str]] = [
|
||||
# ---- p04-gigabit (GigaBIT M1 1.2m Primary Mirror) ----
|
||||
("p04-gigabit", "fact", "mirror-spec",
|
||||
"1.2m borosilicate primary mirror for GigaBIT telescope. F/1.5, lightweight isogrid back structure.",
|
||||
"CDR docs + vault"),
|
||||
("p04-gigabit", "decision", "back-structure",
|
||||
"Option B selected: conical isogrid back structure with variable rib density. Chosen over flat-back for stiffness-to-weight ratio.",
|
||||
"CDR 2026-01"),
|
||||
("p04-gigabit", "decision", "polishing-vendor",
|
||||
"ABB Space (formerly INO) selected as polishing vendor. Contract includes computer-controlled polishing (CCP) and ion beam figuring (IBF).",
|
||||
"Entente de service 2026-01"),
|
||||
("p04-gigabit", "requirement", "surface-quality",
|
||||
"Surface figure accuracy: < 25nm RMS after final figuring. Microroughness: < 2nm RMS.",
|
||||
"CDR requirements"),
|
||||
("p04-gigabit", "contact", "abb-space",
|
||||
"ABB Space (INO), Quebec City. Primary contact for mirror polishing, CCP, and IBF. Project lead: coordinating FDR deliverables.",
|
||||
"vendor records"),
|
||||
("p04-gigabit", "milestone", "fdr",
|
||||
"Final Design Review (FDR) in preparation. Deliverables include interface drawings, thermal analysis, and updated error budget.",
|
||||
"project timeline"),
|
||||
|
||||
# ---- p05-interferometer (Fullum Interferometer) ----
|
||||
("p05-interferometer", "fact", "system-overview",
|
||||
"Custom Fizeau interferometer for in-situ metrology of large optics. Designed for the Fullum observatory polishing facility.",
|
||||
"vault docs"),
|
||||
("p05-interferometer", "decision", "cgh-design",
|
||||
"Computer-generated hologram (CGH) selected for null testing of the 1.2m mirror. Vendor: Diffraction International.",
|
||||
"vendor correspondence"),
|
||||
("p05-interferometer", "requirement", "measurement-accuracy",
|
||||
"Measurement accuracy target: lambda/20 (< 30nm PV) for surface figure verification.",
|
||||
"system requirements"),
|
||||
("p05-interferometer", "fact", "laser-source",
|
||||
"HeNe laser source at 632.8nm. Beam expansion to cover full 1.2m aperture via diverger + CGH.",
|
||||
"optical design docs"),
|
||||
("p05-interferometer", "contact", "diffraction-intl",
|
||||
"Diffraction International: CGH vendor. Fabricates the computer-generated hologram for null testing.",
|
||||
"vendor records"),
|
||||
|
||||
# ---- p06-polisher (Polisher Suite / P11-Polisher-Fullum) ----
|
||||
("p06-polisher", "fact", "suite-overview",
|
||||
"Integrated CNC polishing suite for the Fullum observatory. Includes 3-axis polishing machine, metrology integration, and real-time process control.",
|
||||
"vault docs"),
|
||||
("p06-polisher", "decision", "control-architecture",
|
||||
"Beckhoff TwinCAT 3 selected for real-time motion control. EtherCAT fieldbus for servo drives and I/O.",
|
||||
"architecture docs"),
|
||||
("p06-polisher", "decision", "firmware-split",
|
||||
"Firmware split into safety layer (PLC-level interlocks) and application layer (trajectory generation, adaptive dwell-time).",
|
||||
"architecture docs"),
|
||||
("p06-polisher", "requirement", "axis-travel",
|
||||
"Z-axis: 200mm travel for tool engagement. X/Y: covers 1.2m mirror diameter plus overshoot margin.",
|
||||
"mechanical requirements"),
|
||||
("p06-polisher", "fact", "telemetry",
|
||||
"Real-time telemetry via MQTT. Metrics: spindle RPM, force sensor, temperature probes, position feedback at 1kHz.",
|
||||
"control design docs"),
|
||||
("p06-polisher", "contact", "fullum-observatory",
|
||||
"Fullum Observatory: site where the polishing suite will be installed. Provides infrastructure (power, vibration isolation, clean environment).",
|
||||
"project records"),
|
||||
|
||||
# ---- atomizer-v2 ----
|
||||
("atomizer-v2", "fact", "product-overview",
|
||||
"Atomizer V2: internal project management and multi-agent orchestration platform. War-room based task coordination.",
|
||||
"repo docs"),
|
||||
("atomizer-v2", "decision", "projects-first-architecture",
|
||||
"Migration to projects-first architecture: each project is a workspace with its own agents, tasks, and knowledge.",
|
||||
"war-room-migration-plan-v2.md"),
|
||||
|
||||
# ---- abb-space (P08) ----
|
||||
("abb-space", "fact", "contract-overview",
|
||||
"ABB Space mirror polishing contract. Phase 1: spherical mirror polishing (200mm). Schott Zerodur substrate.",
|
||||
"quotes + correspondence"),
|
||||
("abb-space", "contact", "schott",
|
||||
"Schott AG: substrate supplier for Zerodur mirror blanks. Quote received for 200mm blank.",
|
||||
"vendor records"),
|
||||
|
||||
# ---- atocore ----
|
||||
("atocore", "fact", "architecture",
|
||||
"AtoCore: runtime memory and knowledge layer. FastAPI + SQLite + ChromaDB. Hosted on Dalidou (Docker). Nightly pipeline: backup, extract, triage, synthesis.",
|
||||
"codebase"),
|
||||
("atocore", "decision", "no-api-keys",
|
||||
"No API keys allowed in AtoCore. LLM-assisted features use OAuth via 'claude -p' CLI or equivalent CLI-authenticated paths.",
|
||||
"DEV-LEDGER 2026-04-12"),
|
||||
("atocore", "decision", "storage-separation",
|
||||
"Human-readable sources (vault, drive) and machine operational storage (SQLite, ChromaDB) must remain separate. Machine DB is derived state.",
|
||||
"AGENTS.md"),
|
||||
("atocore", "decision", "extraction-off-hot-path",
|
||||
"Extraction stays off the capture hot path. Batch/manual only. Never block interaction recording with extraction.",
|
||||
"DEV-LEDGER 2026-04-11"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Seed Trusted Project State")
|
||||
parser.add_argument("--base-url", default="http://dalidou:8100")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.base_url.rstrip("/")
|
||||
created = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
for project, category, key, value, source in SEED_ENTRIES:
|
||||
if args.dry_run:
|
||||
print(f" [DRY] {project}/{category}/{key}: {value[:60]}...")
|
||||
created += 1
|
||||
continue
|
||||
|
||||
body = json.dumps({
|
||||
"project": project,
|
||||
"category": category,
|
||||
"key": key,
|
||||
"value": value,
|
||||
"source": source,
|
||||
"confidence": 1.0,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{base}/project/state",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
result = json.loads(resp.read())
|
||||
if result.get("created"):
|
||||
created += 1
|
||||
print(f" + {project}/{category}/{key}")
|
||||
else:
|
||||
skipped += 1
|
||||
print(f" = {project}/{category}/{key} (already exists)")
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
print(f" ! {project}/{category}/{key}: {e}", file=sys.stderr)
|
||||
|
||||
print(f"\nDone: {created} created, {skipped} skipped, {errors} errors")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
168
scripts/synthesize_projects.py
Normal file
168
scripts/synthesize_projects.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Weekly project synthesis — LLM-generated 'current state' paragraph per project.
|
||||
|
||||
Reads each registered project's state entries, memories, and entities,
|
||||
asks sonnet for a 3-5 sentence synthesis, and caches it under
|
||||
project_state/status/synthesis_cache. The wiki's project page reads
|
||||
this cached synthesis as the top band.
|
||||
|
||||
Runs weekly via cron (or manually). Cheap — one LLM call per project.
|
||||
|
||||
Usage:
|
||||
python3 scripts/synthesize_projects.py --base-url http://localhost:8100
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://localhost:8100")
|
||||
DEFAULT_MODEL = os.environ.get("ATOCORE_SYNTHESIS_MODEL", "sonnet")
|
||||
TIMEOUT_S = 60
|
||||
|
||||
SYSTEM_PROMPT = """You are summarizing the current state of an engineering project for a personal context engine called AtoCore.
|
||||
|
||||
You will receive:
|
||||
- Project state entries (decisions, requirements, status)
|
||||
- Active memories tagged to this project
|
||||
- Entity graph (subsystems, components, materials, decisions)
|
||||
|
||||
Write a 3-5 sentence synthesis covering:
|
||||
1. What the project is and its current stage
|
||||
2. The key locked-in decisions and architecture
|
||||
3. What the next focus is
|
||||
|
||||
Rules:
|
||||
- Plain prose, no bullet lists
|
||||
- Factual, grounded in what the data says — don't invent or speculate
|
||||
- Present tense
|
||||
- Under 500 characters total
|
||||
- No markdown formatting, just prose
|
||||
- If the data is sparse, say so honestly ("limited project data available")
|
||||
|
||||
Output ONLY the synthesis paragraph. No preamble, no JSON, no markdown headers."""
|
||||
|
||||
|
||||
_cwd = None
|
||||
|
||||
|
||||
def get_cwd():
|
||||
global _cwd
|
||||
if _cwd is None:
|
||||
_cwd = tempfile.mkdtemp(prefix="ato-synth-")
|
||||
return _cwd
|
||||
|
||||
|
||||
def api_get(base_url, path):
|
||||
with urllib.request.urlopen(f"{base_url}{path}", timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def api_post(base_url, path, body):
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def synthesize_project(base_url, project_id, model):
|
||||
# Gather context
|
||||
state = api_get(base_url, f"/project/state/{project_id}").get("entries", [])
|
||||
memories = api_get(base_url, f"/memory?project={project_id}&active_only=true&limit=20").get("memories", [])
|
||||
entities = api_get(base_url, f"/entities?project={project_id}&limit=50").get("entities", [])
|
||||
|
||||
if not (state or memories or entities):
|
||||
return None
|
||||
|
||||
lines = [f"PROJECT: {project_id}\n"]
|
||||
if state:
|
||||
lines.append("STATE ENTRIES:")
|
||||
for e in state[:15]:
|
||||
if e.get("key") == "synthesis_cache":
|
||||
continue
|
||||
lines.append(f" [{e['category']}] {e['key']}: {e['value'][:200]}")
|
||||
|
||||
if memories:
|
||||
lines.append("\nACTIVE MEMORIES:")
|
||||
for m in memories[:10]:
|
||||
lines.append(f" [{m['memory_type']}] {m['content'][:200]}")
|
||||
|
||||
if entities:
|
||||
lines.append("\nENTITIES:")
|
||||
by_type = {}
|
||||
for e in entities:
|
||||
by_type.setdefault(e["entity_type"], []).append(e["name"])
|
||||
for t, names in by_type.items():
|
||||
lines.append(f" {t}: {', '.join(names[:8])}")
|
||||
|
||||
user_msg = "\n".join(lines) + "\n\nWrite the synthesis paragraph now."
|
||||
|
||||
if not shutil.which("claude"):
|
||||
print(f" ! claude CLI not available, skipping {project_id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "-p", "--model", model,
|
||||
"--append-system-prompt", SYSTEM_PROMPT,
|
||||
"--disable-slash-commands",
|
||||
user_msg],
|
||||
capture_output=True, text=True, timeout=TIMEOUT_S,
|
||||
cwd=get_cwd(), encoding="utf-8", errors="replace",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" ! subprocess failed for {project_id}: {e}")
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f" ! claude exit {result.returncode} for {project_id}")
|
||||
return None
|
||||
|
||||
synthesis = (result.stdout or "").strip()
|
||||
if not synthesis or len(synthesis) < 50:
|
||||
return None
|
||||
return synthesis[:1000]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL)
|
||||
parser.add_argument("--project", default=None, help="single project to synthesize")
|
||||
args = parser.parse_args()
|
||||
|
||||
projects = api_get(args.base_url, "/projects").get("projects", [])
|
||||
if args.project:
|
||||
projects = [p for p in projects if p["id"] == args.project]
|
||||
|
||||
print(f"Synthesizing {len(projects)} project(s) with {args.model}...")
|
||||
|
||||
for p in projects:
|
||||
pid = p["id"]
|
||||
print(f"\n- {pid}")
|
||||
synthesis = synthesize_project(args.base_url, pid, args.model)
|
||||
if synthesis:
|
||||
print(f" {synthesis[:200]}...")
|
||||
try:
|
||||
api_post(args.base_url, "/project/state", {
|
||||
"project": pid,
|
||||
"category": "status",
|
||||
"key": "synthesis_cache",
|
||||
"value": synthesis,
|
||||
"source": "weekly synthesis pass",
|
||||
})
|
||||
print(f" + cached")
|
||||
except Exception as e:
|
||||
print(f" ! save failed: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
87
scripts/windows/atocore-backup-pull.ps1
Normal file
87
scripts/windows/atocore-backup-pull.ps1
Normal file
@@ -0,0 +1,87 @@
|
||||
# atocore-backup-pull.ps1
|
||||
#
|
||||
# Pull the latest AtoCore backup snapshot from Dalidou to this Windows machine.
|
||||
# Designed to be run by Windows Task Scheduler. Fail-open by design -- if
|
||||
# Dalidou is unreachable (laptop on the road, etc.), exit cleanly without error.
|
||||
#
|
||||
# Usage (manual test):
|
||||
# powershell.exe -ExecutionPolicy Bypass -File atocore-backup-pull.ps1
|
||||
#
|
||||
# Scheduled task: see docs/windows-backup-setup.md for Task Scheduler config.
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
# --- Configuration ---
|
||||
$Remote = "papa@dalidou"
|
||||
$RemoteSnapshots = "/srv/storage/atocore/backups/snapshots"
|
||||
$LocalBackupDir = "$env:USERPROFILE\Documents\ATOCore_Backups"
|
||||
$LogDir = "$LocalBackupDir\_logs"
|
||||
$ReachabilityTest = 5 # seconds timeout for SSH probe
|
||||
|
||||
# --- Setup ---
|
||||
if (-not (Test-Path $LocalBackupDir)) {
|
||||
New-Item -ItemType Directory -Path $LocalBackupDir -Force | Out-Null
|
||||
}
|
||||
if (-not (Test-Path $LogDir)) {
|
||||
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$Timestamp = Get-Date -Format "yyyy-MM-dd_HHmmss"
|
||||
$LogFile = "$LogDir\backup-$Timestamp.log"
|
||||
|
||||
function Log($msg) {
|
||||
$line = "[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $msg
|
||||
Write-Host $line
|
||||
Add-Content -Path $LogFile -Value $line
|
||||
}
|
||||
|
||||
Log "=== AtoCore backup pull starting ==="
|
||||
Log "Remote: $Remote"
|
||||
Log "Local target: $LocalBackupDir"
|
||||
|
||||
# --- Reachability check: fail open if Dalidou is offline ---
|
||||
Log "Checking Dalidou reachability..."
|
||||
$probe = & ssh -o ConnectTimeout=$ReachabilityTest -o BatchMode=yes `
|
||||
-o StrictHostKeyChecking=accept-new `
|
||||
$Remote "echo ok" 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or $probe -ne "ok") {
|
||||
Log "Dalidou unreachable ($probe) -- fail-open exit"
|
||||
exit 0
|
||||
}
|
||||
Log "Dalidou reachable."
|
||||
|
||||
# --- Pull the entire snapshots directory ---
|
||||
# Dalidou's retention policy (7 daily + 4 weekly + 6 monthly) already caps
|
||||
# the snapshot count, so pulling the whole dir is bounded and simple. scp
|
||||
# will overwrite local files -- we rely on this to pick up new snapshots.
|
||||
Log "Pulling snapshots via scp..."
|
||||
$LocalSnapshotsDir = Join-Path $LocalBackupDir "snapshots"
|
||||
if (-not (Test-Path $LocalSnapshotsDir)) {
|
||||
New-Item -ItemType Directory -Path $LocalSnapshotsDir -Force | Out-Null
|
||||
}
|
||||
|
||||
& scp -o BatchMode=yes -r "${Remote}:${RemoteSnapshots}/*" "$LocalSnapshotsDir\" 2>&1 |
|
||||
ForEach-Object { Add-Content -Path $LogFile -Value $_ }
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Log "scp failed with exit $LASTEXITCODE"
|
||||
exit 0 # fail-open
|
||||
}
|
||||
|
||||
# --- Stats ---
|
||||
$snapshots = Get-ChildItem -Path $LocalSnapshotsDir -Directory |
|
||||
Where-Object { $_.Name -match "^\d{8}T\d{6}Z$" } |
|
||||
Sort-Object Name -Descending
|
||||
|
||||
$totalSize = (Get-ChildItem $LocalSnapshotsDir -Recurse -File | Measure-Object -Property Length -Sum).Sum
|
||||
$SizeMB = [math]::Round($totalSize / 1MB, 2)
|
||||
$latest = if ($snapshots.Count -gt 0) { $snapshots[0].Name } else { "(none)" }
|
||||
|
||||
Log ("Pulled {0} snapshots successfully (total {1} MB, latest: {2})" -f $snapshots.Count, $SizeMB, $latest)
|
||||
Log "=== backup complete ==="
|
||||
|
||||
# --- Log retention: keep last 30 log files ---
|
||||
Get-ChildItem -Path $LogDir -Filter "backup-*.log" |
|
||||
Sort-Object Name -Descending |
|
||||
Select-Object -Skip 30 |
|
||||
ForEach-Object { Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue }
|
||||
@@ -1,3 +1,15 @@
|
||||
"""AtoCore — Personal Context Engine."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
# Bumped when a commit meaningfully changes the API surface, schema, or
|
||||
# user-visible behavior. The /health endpoint reports this value so
|
||||
# deployment drift is immediately visible: if the running service's
|
||||
# /health reports an older version than the main branch's __version__,
|
||||
# the deployment is stale and needs a redeploy (see
|
||||
# docs/dalidou-deployment.md and deploy/dalidou/deploy.sh).
|
||||
#
|
||||
# History:
|
||||
# 0.1.0 Phase 0/0.5/1/2/3/5/7 baseline
|
||||
# 0.2.0 Phase 9 reflection loop (capture/reinforce/extract + review
|
||||
# queue), shared client v0.2.0, project identity
|
||||
# canonicalization at every service-layer entry point
|
||||
__version__ = "0.2.0"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,21 @@ class Settings(BaseSettings):
|
||||
|
||||
@property
|
||||
def resolved_project_registry_path(self) -> Path:
|
||||
"""Path to the project registry JSON file.
|
||||
|
||||
If ``ATOCORE_PROJECT_REGISTRY_DIR`` env var is set, the registry
|
||||
lives at ``<that dir>/project-registry.json``. Otherwise falls
|
||||
back to the configured ``project_registry_path`` field.
|
||||
|
||||
This lets Docker deployments point at a mounted volume via env
|
||||
var without the ephemeral in-image ``/app/config/`` getting
|
||||
wiped on every rebuild.
|
||||
"""
|
||||
import os
|
||||
|
||||
registry_dir = os.environ.get("ATOCORE_PROJECT_REGISTRY_DIR", "").strip()
|
||||
if registry_dir:
|
||||
return Path(registry_dir) / "project-registry.json"
|
||||
return self._resolve_path(self.project_registry_path)
|
||||
|
||||
@property
|
||||
|
||||
@@ -14,6 +14,8 @@ import atocore.config as _config
|
||||
from atocore.context.project_state import format_project_state, get_state
|
||||
from atocore.memory.service import get_memories_for_context
|
||||
from atocore.observability.logger import get_logger
|
||||
from atocore.engineering.service import get_entities, get_entity_with_context
|
||||
from atocore.projects.registry import resolve_project_name
|
||||
from atocore.retrieval.retriever import ChunkResult, retrieve
|
||||
|
||||
log = get_logger("context_builder")
|
||||
@@ -28,7 +30,20 @@ SYSTEM_PREFIX = (
|
||||
# Budget allocation (per Master Plan section 9):
|
||||
# identity: 5%, preferences: 5%, project state: 20%, retrieval: 60%+
|
||||
PROJECT_STATE_BUDGET_RATIO = 0.20
|
||||
MEMORY_BUDGET_RATIO = 0.10 # 5% identity + 5% preference
|
||||
MEMORY_BUDGET_RATIO = 0.05 # identity + preference; lowered from 0.10 to avoid squeezing project memories and chunks
|
||||
# Project-scoped memories (project/knowledge/episodic) are the outlet
|
||||
# for the Phase 9 reflection loop on the retrieval side. Budget sits
|
||||
# between identity/preference and retrieved chunks so a reinforced
|
||||
# memory can actually reach the model.
|
||||
PROJECT_MEMORY_BUDGET_RATIO = 0.25
|
||||
PROJECT_MEMORY_TYPES = ["project", "knowledge", "episodic"]
|
||||
# General domain knowledge — unscoped memories (project="") that surface
|
||||
# in every context pack regardless of project hint. These are earned
|
||||
# engineering insights that apply across projects (e.g., "Preston removal
|
||||
# model breaks down below 5N because the contact assumption fails").
|
||||
DOMAIN_KNOWLEDGE_BUDGET_RATIO = 0.10
|
||||
DOMAIN_KNOWLEDGE_TYPES = ["knowledge"]
|
||||
ENGINEERING_CONTEXT_BUDGET_RATIO = 0.10
|
||||
|
||||
# Last built context pack for debug inspection
|
||||
_last_context_pack: "ContextPack | None" = None
|
||||
@@ -50,6 +65,12 @@ class ContextPack:
|
||||
project_state_chars: int = 0
|
||||
memory_text: str = ""
|
||||
memory_chars: int = 0
|
||||
project_memory_text: str = ""
|
||||
project_memory_chars: int = 0
|
||||
domain_knowledge_text: str = ""
|
||||
domain_knowledge_chars: int = 0
|
||||
engineering_context_text: str = ""
|
||||
engineering_context_chars: int = 0
|
||||
total_chars: int = 0
|
||||
budget: int = 0
|
||||
budget_remaining: int = 0
|
||||
@@ -84,8 +105,16 @@ def build_context(
|
||||
max(0, int(budget * PROJECT_STATE_BUDGET_RATIO)),
|
||||
)
|
||||
|
||||
if project_hint:
|
||||
state_entries = get_state(project_hint)
|
||||
# Canonicalize the project hint through the registry so callers
|
||||
# can pass an alias (`p05`, `gigabit`) and still find trusted
|
||||
# state stored under the canonical project id. The same helper
|
||||
# is used everywhere a project name crosses a trust boundary
|
||||
# (project_state, memories, interactions). When the registry has
|
||||
# no entry the helper returns the input unchanged so hand-curated
|
||||
# state that predates the registry still works.
|
||||
canonical_project = resolve_project_name(project_hint) if project_hint else ""
|
||||
if canonical_project:
|
||||
state_entries = get_state(canonical_project)
|
||||
if state_entries:
|
||||
project_state_text = format_project_state(state_entries)
|
||||
project_state_text, project_state_chars = _truncate_text_block(
|
||||
@@ -98,10 +127,70 @@ def build_context(
|
||||
memory_text, memory_chars = get_memories_for_context(
|
||||
memory_types=["identity", "preference"],
|
||||
budget=memory_budget,
|
||||
query=user_prompt,
|
||||
)
|
||||
|
||||
# 2b. Get project-scoped memories (third precedence). Only
|
||||
# populated when a canonical project is in scope — cross-project
|
||||
# memory bleed would rot the pack. Active-only filtering is
|
||||
# handled by the shared min_confidence=0.5 gate inside
|
||||
# get_memories_for_context.
|
||||
project_memory_text = ""
|
||||
project_memory_chars = 0
|
||||
if canonical_project:
|
||||
project_memory_budget = min(
|
||||
int(budget * PROJECT_MEMORY_BUDGET_RATIO),
|
||||
max(budget - project_state_chars - memory_chars, 0),
|
||||
)
|
||||
project_memory_text, project_memory_chars = get_memories_for_context(
|
||||
memory_types=PROJECT_MEMORY_TYPES,
|
||||
project=canonical_project,
|
||||
budget=project_memory_budget,
|
||||
header="--- Project Memories ---",
|
||||
footer="--- End Project Memories ---",
|
||||
query=user_prompt,
|
||||
)
|
||||
|
||||
# 2c. Domain knowledge — cross-project earned insight with project=""
|
||||
# that surfaces regardless of which project the query is about.
|
||||
domain_knowledge_text = ""
|
||||
domain_knowledge_chars = 0
|
||||
domain_budget = min(
|
||||
int(budget * DOMAIN_KNOWLEDGE_BUDGET_RATIO),
|
||||
max(budget - project_state_chars - memory_chars - project_memory_chars, 0),
|
||||
)
|
||||
if domain_budget > 0:
|
||||
domain_knowledge_text, domain_knowledge_chars = get_memories_for_context(
|
||||
memory_types=DOMAIN_KNOWLEDGE_TYPES,
|
||||
project="",
|
||||
budget=domain_budget,
|
||||
header="--- Domain Knowledge ---",
|
||||
footer="--- End Domain Knowledge ---",
|
||||
query=user_prompt,
|
||||
)
|
||||
|
||||
# 2d. Engineering context — structured entity/relationship data
|
||||
# when the query matches a known entity name.
|
||||
engineering_context_text = ""
|
||||
engineering_context_chars = 0
|
||||
if canonical_project:
|
||||
eng_budget = min(
|
||||
int(budget * ENGINEERING_CONTEXT_BUDGET_RATIO),
|
||||
max(budget - project_state_chars - memory_chars
|
||||
- project_memory_chars - domain_knowledge_chars, 0),
|
||||
)
|
||||
if eng_budget > 0:
|
||||
engineering_context_text = _build_engineering_context(
|
||||
user_prompt, canonical_project, eng_budget,
|
||||
)
|
||||
engineering_context_chars = len(engineering_context_text)
|
||||
|
||||
# 3. Calculate remaining budget for retrieval
|
||||
retrieval_budget = budget - project_state_chars - memory_chars
|
||||
retrieval_budget = (
|
||||
budget - project_state_chars - memory_chars
|
||||
- project_memory_chars - domain_knowledge_chars
|
||||
- engineering_context_chars
|
||||
)
|
||||
|
||||
# 4. Retrieve candidates
|
||||
candidates = (
|
||||
@@ -121,11 +210,17 @@ def build_context(
|
||||
selected = _select_within_budget(scored, max(retrieval_budget, 0))
|
||||
|
||||
# 7. Format full context
|
||||
formatted = _format_full_context(project_state_text, memory_text, selected)
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, selected,
|
||||
)
|
||||
if len(formatted) > budget:
|
||||
formatted, selected = _trim_context_to_budget(
|
||||
project_state_text,
|
||||
memory_text,
|
||||
project_memory_text,
|
||||
domain_knowledge_text,
|
||||
engineering_context_text,
|
||||
selected,
|
||||
budget,
|
||||
)
|
||||
@@ -135,6 +230,9 @@ def build_context(
|
||||
|
||||
project_state_chars = len(project_state_text)
|
||||
memory_chars = len(memory_text)
|
||||
project_memory_chars = len(project_memory_text)
|
||||
domain_knowledge_chars = len(domain_knowledge_text)
|
||||
engineering_context_chars = len(engineering_context_text)
|
||||
retrieval_chars = sum(c.char_count for c in selected)
|
||||
total_chars = len(formatted)
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
@@ -145,6 +243,12 @@ def build_context(
|
||||
project_state_chars=project_state_chars,
|
||||
memory_text=memory_text,
|
||||
memory_chars=memory_chars,
|
||||
project_memory_text=project_memory_text,
|
||||
project_memory_chars=project_memory_chars,
|
||||
domain_knowledge_text=domain_knowledge_text,
|
||||
domain_knowledge_chars=domain_knowledge_chars,
|
||||
engineering_context_text=engineering_context_text,
|
||||
engineering_context_chars=engineering_context_chars,
|
||||
total_chars=total_chars,
|
||||
budget=budget,
|
||||
budget_remaining=budget - total_chars,
|
||||
@@ -162,6 +266,9 @@ def build_context(
|
||||
chunks_used=len(selected),
|
||||
project_state_chars=project_state_chars,
|
||||
memory_chars=memory_chars,
|
||||
project_memory_chars=project_memory_chars,
|
||||
domain_knowledge_chars=domain_knowledge_chars,
|
||||
engineering_context_chars=engineering_context_chars,
|
||||
retrieval_chars=retrieval_chars,
|
||||
total_chars=total_chars,
|
||||
budget_remaining=budget - total_chars,
|
||||
@@ -241,7 +348,10 @@ def _select_within_budget(
|
||||
def _format_full_context(
|
||||
project_state_text: str,
|
||||
memory_text: str,
|
||||
chunks: list[ContextChunk],
|
||||
project_memory_text: str,
|
||||
domain_knowledge_text: str,
|
||||
engineering_context_text: str = "",
|
||||
chunks: list[ContextChunk] | None = None,
|
||||
) -> str:
|
||||
"""Format project state + memories + retrieved chunks into full context block."""
|
||||
parts = []
|
||||
@@ -256,7 +366,22 @@ def _format_full_context(
|
||||
parts.append(memory_text)
|
||||
parts.append("")
|
||||
|
||||
# 3. Retrieved chunks (lowest trust)
|
||||
# 3. Project-scoped memories (third trust level)
|
||||
if project_memory_text:
|
||||
parts.append(project_memory_text)
|
||||
parts.append("")
|
||||
|
||||
# 4. Domain knowledge (cross-project earned insight)
|
||||
if domain_knowledge_text:
|
||||
parts.append(domain_knowledge_text)
|
||||
parts.append("")
|
||||
|
||||
# 5. Engineering context (structured entity/relationship data)
|
||||
if engineering_context_text:
|
||||
parts.append(engineering_context_text)
|
||||
parts.append("")
|
||||
|
||||
# 6. Retrieved chunks (lowest trust)
|
||||
if chunks:
|
||||
parts.append("--- AtoCore Retrieved Context ---")
|
||||
if project_state_text:
|
||||
@@ -268,7 +393,7 @@ def _format_full_context(
|
||||
parts.append(chunk.content)
|
||||
parts.append("")
|
||||
parts.append("--- End Context ---")
|
||||
elif not project_state_text and not memory_text:
|
||||
elif not project_state_text and not memory_text and not project_memory_text and not domain_knowledge_text and not engineering_context_text:
|
||||
parts.append("--- AtoCore Context ---\nNo relevant context found.\n--- End Context ---")
|
||||
|
||||
return "\n".join(parts)
|
||||
@@ -290,6 +415,8 @@ def _pack_to_dict(pack: ContextPack) -> dict:
|
||||
"project_hint": pack.project_hint,
|
||||
"project_state_chars": pack.project_state_chars,
|
||||
"memory_chars": pack.memory_chars,
|
||||
"project_memory_chars": pack.project_memory_chars,
|
||||
"domain_knowledge_chars": pack.domain_knowledge_chars,
|
||||
"chunks_used": len(pack.chunks_used),
|
||||
"total_chars": pack.total_chars,
|
||||
"budget": pack.budget,
|
||||
@@ -297,6 +424,9 @@ def _pack_to_dict(pack: ContextPack) -> dict:
|
||||
"duration_ms": pack.duration_ms,
|
||||
"has_project_state": bool(pack.project_state_text),
|
||||
"has_memories": bool(pack.memory_text),
|
||||
"has_project_memories": bool(pack.project_memory_text),
|
||||
"has_domain_knowledge": bool(pack.domain_knowledge_text),
|
||||
"has_engineering_context": bool(pack.engineering_context_text),
|
||||
"chunks": [
|
||||
{
|
||||
"source_file": c.source_file,
|
||||
@@ -310,6 +440,100 @@ def _pack_to_dict(pack: ContextPack) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _build_engineering_context(
|
||||
query: str,
|
||||
project: str,
|
||||
budget: int,
|
||||
) -> str:
|
||||
"""Find entities matching the query and format their context.
|
||||
|
||||
Uses simple word-overlap matching between query tokens and entity
|
||||
names to find relevant entities, then formats the top match with
|
||||
its relationships as a compact text band.
|
||||
"""
|
||||
if budget < 100:
|
||||
return ""
|
||||
|
||||
from atocore.memory.reinforcement import _normalize, _tokenize
|
||||
|
||||
query_tokens = _tokenize(_normalize(query))
|
||||
if not query_tokens:
|
||||
return ""
|
||||
|
||||
try:
|
||||
entities = get_entities(project=project, limit=100)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if not entities:
|
||||
return ""
|
||||
|
||||
scored: list[tuple[int, "Entity"]] = []
|
||||
for ent in entities:
|
||||
name_tokens = _tokenize(_normalize(ent.name))
|
||||
desc_tokens = _tokenize(_normalize(ent.description))
|
||||
overlap = len(query_tokens & (name_tokens | desc_tokens))
|
||||
if overlap > 0:
|
||||
scored.append((overlap, ent))
|
||||
|
||||
if not scored:
|
||||
return ""
|
||||
|
||||
scored.sort(key=lambda t: t[0], reverse=True)
|
||||
best_entity = scored[0][1]
|
||||
|
||||
try:
|
||||
ctx = get_entity_with_context(best_entity.id)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if ctx is None:
|
||||
return ""
|
||||
|
||||
lines = ["--- Engineering Context ---"]
|
||||
lines.append(f"[{best_entity.entity_type}] {best_entity.name}")
|
||||
if best_entity.description:
|
||||
lines.append(f" {best_entity.description[:150]}")
|
||||
|
||||
for rel in ctx["relationships"][:8]:
|
||||
other_id = (
|
||||
rel.target_entity_id
|
||||
if rel.source_entity_id == best_entity.id
|
||||
else rel.source_entity_id
|
||||
)
|
||||
other = ctx["related_entities"].get(other_id)
|
||||
if other:
|
||||
direction = "->" if rel.source_entity_id == best_entity.id else "<-"
|
||||
lines.append(
|
||||
f" {direction} {rel.relationship_type} [{other.entity_type}] {other.name}"
|
||||
)
|
||||
|
||||
# Phase 5H: append a compact gaps summary so the LLM always sees
|
||||
# "what we're currently missing" alongside the entity neighborhood.
|
||||
# This is the director's most-used insight — orphan requirements,
|
||||
# risky decisions, unsupported claims — surfaced in every context pack
|
||||
# for project-scoped queries.
|
||||
try:
|
||||
from atocore.engineering.queries import all_gaps as _all_gaps
|
||||
gaps = _all_gaps(project)
|
||||
orphan_n = gaps["orphan_requirements"]["count"]
|
||||
risky_n = gaps["risky_decisions"]["count"]
|
||||
unsup_n = gaps["unsupported_claims"]["count"]
|
||||
if orphan_n or risky_n or unsup_n:
|
||||
lines.append("")
|
||||
lines.append(f"Gaps: {orphan_n} orphan reqs, {risky_n} risky decisions, {unsup_n} unsupported claims")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lines.append("--- End Engineering Context ---")
|
||||
text = "\n".join(lines)
|
||||
|
||||
if len(text) > budget:
|
||||
text = text[:budget - 3].rstrip() + "..."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _truncate_text_block(text: str, budget: int) -> tuple[str, int]:
|
||||
"""Trim a formatted text block so trusted tiers cannot exceed the total budget."""
|
||||
if budget <= 0 or not text:
|
||||
@@ -326,26 +550,67 @@ def _truncate_text_block(text: str, budget: int) -> tuple[str, int]:
|
||||
def _trim_context_to_budget(
|
||||
project_state_text: str,
|
||||
memory_text: str,
|
||||
project_memory_text: str,
|
||||
domain_knowledge_text: str,
|
||||
engineering_context_text: str,
|
||||
chunks: list[ContextChunk],
|
||||
budget: int,
|
||||
) -> tuple[str, list[ContextChunk]]:
|
||||
"""Trim retrieval first, then memory, then project state until formatted context fits."""
|
||||
"""Trim retrieval -> engineering -> domain -> project memories -> identity -> state."""
|
||||
kept_chunks = list(chunks)
|
||||
formatted = _format_full_context(project_state_text, memory_text, kept_chunks)
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, kept_chunks,
|
||||
)
|
||||
while len(formatted) > budget and kept_chunks:
|
||||
kept_chunks.pop()
|
||||
formatted = _format_full_context(project_state_text, memory_text, kept_chunks)
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, kept_chunks,
|
||||
)
|
||||
|
||||
if len(formatted) <= budget:
|
||||
return formatted, kept_chunks
|
||||
|
||||
# Drop engineering context first.
|
||||
engineering_context_text = ""
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, kept_chunks,
|
||||
)
|
||||
if len(formatted) <= budget:
|
||||
return formatted, kept_chunks
|
||||
|
||||
# Drop domain knowledge next.
|
||||
domain_knowledge_text, _ = _truncate_text_block(domain_knowledge_text, 0)
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, kept_chunks,
|
||||
)
|
||||
if len(formatted) <= budget:
|
||||
return formatted, kept_chunks
|
||||
|
||||
project_memory_text, _ = _truncate_text_block(
|
||||
project_memory_text,
|
||||
max(budget - len(project_state_text) - len(memory_text), 0),
|
||||
)
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, kept_chunks,
|
||||
)
|
||||
if len(formatted) <= budget:
|
||||
return formatted, kept_chunks
|
||||
|
||||
memory_text, _ = _truncate_text_block(memory_text, max(budget - len(project_state_text), 0))
|
||||
formatted = _format_full_context(project_state_text, memory_text, kept_chunks)
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, kept_chunks,
|
||||
)
|
||||
if len(formatted) <= budget:
|
||||
return formatted, kept_chunks
|
||||
|
||||
project_state_text, _ = _truncate_text_block(project_state_text, budget)
|
||||
formatted = _format_full_context(project_state_text, "", [])
|
||||
formatted = _format_full_context(project_state_text, "", "", "", [])
|
||||
if len(formatted) > budget:
|
||||
formatted, _ = _truncate_text_block(formatted, budget)
|
||||
return formatted, []
|
||||
|
||||
@@ -18,6 +18,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from atocore.models.database import get_connection
|
||||
from atocore.observability.logger import get_logger
|
||||
from atocore.projects.registry import resolve_project_name
|
||||
|
||||
log = get_logger("project_state")
|
||||
|
||||
@@ -101,11 +102,19 @@ def set_state(
|
||||
source: str = "",
|
||||
confidence: float = 1.0,
|
||||
) -> ProjectStateEntry:
|
||||
"""Set or update a project state entry. Upsert semantics."""
|
||||
"""Set or update a project state entry. Upsert semantics.
|
||||
|
||||
The ``project_name`` is canonicalized through the registry so a
|
||||
caller passing an alias (``p05``) ends up writing into the same
|
||||
row as the canonical id (``p05-interferometer``). Without this
|
||||
step, alias and canonical names would create two parallel
|
||||
project rows and fragmented state.
|
||||
"""
|
||||
if category not in CATEGORIES:
|
||||
raise ValueError(f"Invalid category '{category}'. Must be one of: {CATEGORIES}")
|
||||
_validate_confidence(confidence)
|
||||
|
||||
project_name = resolve_project_name(project_name)
|
||||
project_id = ensure_project(project_name)
|
||||
entry_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
@@ -153,7 +162,12 @@ def get_state(
|
||||
category: str | None = None,
|
||||
active_only: bool = True,
|
||||
) -> list[ProjectStateEntry]:
|
||||
"""Get project state entries, optionally filtered by category."""
|
||||
"""Get project state entries, optionally filtered by category.
|
||||
|
||||
The lookup is canonicalized through the registry so an alias hint
|
||||
finds the same rows as the canonical id.
|
||||
"""
|
||||
project_name = resolve_project_name(project_name)
|
||||
with get_connection() as conn:
|
||||
project = conn.execute(
|
||||
"SELECT id FROM projects WHERE lower(name) = lower(?)", (project_name,)
|
||||
@@ -191,7 +205,12 @@ def get_state(
|
||||
|
||||
|
||||
def invalidate_state(project_name: str, category: str, key: str) -> bool:
|
||||
"""Mark a project state entry as superseded."""
|
||||
"""Mark a project state entry as superseded.
|
||||
|
||||
The lookup is canonicalized through the registry so an alias is
|
||||
treated as the canonical project for the invalidation lookup.
|
||||
"""
|
||||
project_name = resolve_project_name(project_name)
|
||||
with get_connection() as conn:
|
||||
project = conn.execute(
|
||||
"SELECT id FROM projects WHERE lower(name) = lower(?)", (project_name,)
|
||||
|
||||
16
src/atocore/engineering/__init__.py
Normal file
16
src/atocore/engineering/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Engineering Knowledge Layer — typed entities and relationships.
|
||||
|
||||
Layer 2 of the AtoCore architecture. Sits on top of the core machine
|
||||
layer (memories, project state, retrieval) and adds structured
|
||||
engineering objects with typed relationships so queries like "what
|
||||
requirements does this component satisfy" can be answered directly
|
||||
instead of relying on flat text search.
|
||||
|
||||
V1 entity types (from docs/architecture/engineering-ontology-v1.md):
|
||||
Component, Subsystem, Requirement, Constraint, Decision, Material,
|
||||
Parameter, Interface
|
||||
|
||||
V1 relationship types:
|
||||
CONTAINS, PART_OF, INTERFACES_WITH, SATISFIES, CONSTRAINED_BY,
|
||||
AFFECTED_BY_DECISION, ANALYZED_BY, VALIDATED_BY, DEPENDS_ON
|
||||
"""
|
||||
194
src/atocore/engineering/_graduation_prompt.py
Normal file
194
src/atocore/engineering/_graduation_prompt.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Shared LLM prompt for memory → entity graduation (Phase 5F).
|
||||
|
||||
Mirrors the pattern of ``atocore.memory._llm_prompt``: stdlib-only so both
|
||||
the container extractor path and the host-side graduate_memories.py script
|
||||
use the same system prompt and parser, eliminating drift.
|
||||
|
||||
Graduation asks: "does this active memory describe a TYPED engineering entity
|
||||
that belongs in the knowledge graph?" If yes, produce an entity candidate
|
||||
with type + name + description + zero-or-more relationship hints. If no,
|
||||
return null so the memory stays as-is.
|
||||
|
||||
Design note: we DON'T ask the LLM to resolve targets of relationships (e.g.,
|
||||
"connect to Subsystem 'Optics'"). That's done in a second pass after human
|
||||
review — partly to keep this prompt cheap, partly because name-matching
|
||||
targets across projects is a hard problem worth its own pass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
GRADUATION_PROMPT_VERSION = "graduate-0.1.0"
|
||||
MAX_CONTENT_CHARS = 1500
|
||||
|
||||
ENTITY_TYPES = {
|
||||
"project",
|
||||
"system",
|
||||
"subsystem",
|
||||
"component",
|
||||
"interface",
|
||||
"requirement",
|
||||
"constraint",
|
||||
"decision",
|
||||
"material",
|
||||
"parameter",
|
||||
"analysis_model",
|
||||
"result",
|
||||
"validation_claim",
|
||||
"vendor",
|
||||
"process",
|
||||
}
|
||||
|
||||
SYSTEM_PROMPT = """You are a knowledge-graph curator for an engineering firm's context system (AtoCore).
|
||||
|
||||
Your job: given one active MEMORY (a curated fact about an engineering project), decide whether it describes a TYPED engineering entity that belongs in the structured graph. If yes, emit the entity candidate. If no, return null.
|
||||
|
||||
A memory gets graduated when its content names a specific thing that has lifecycle, relationships, or cross-references in engineering work. A memory stays as-is when it's a general observation, preference, or loose context.
|
||||
|
||||
ENTITY TYPES (choose the best fit):
|
||||
|
||||
- project — a named project (usually already registered; rare to emit)
|
||||
- subsystem — a named chunk of a system with defined boundaries (e.g., "Primary Optics", "Cable Tensioning", "Motion Control")
|
||||
- component — a discrete physical or logical part (e.g., "Primary Mirror", "Pivot Pin", "Z-axis Servo Drive")
|
||||
- interface — a named boundary between two subsystems/components (e.g., "Mirror-to-Cell mounting interface")
|
||||
- requirement — a "must" or "shall" statement (e.g., "Surface figure < 25nm RMS")
|
||||
- constraint — a non-negotiable limit (e.g., "Thermal operating range 0-40°C")
|
||||
- decision — a committed design direction (e.g., "Selected Zerodur over ULE for primary blank")
|
||||
- material — a named material used in a component (e.g., "Zerodur", "Invar 36")
|
||||
- parameter — a specific named value or assumption (e.g., "Ambient temperature 22°C", "Lead time 6 weeks")
|
||||
- analysis_model — a named FEA / optical / thermal model (e.g., "Preston wear model v2")
|
||||
- result — a named measurement or simulation output (e.g., "FEA thermal sweep 2026-03")
|
||||
- validation_claim — an asserted claim to be backed by evidence (e.g., "Margin is adequate for full envelope")
|
||||
- vendor — a supplier / partner entity (e.g., "Schott AG", "ABB Space", "Nabeel")
|
||||
- process — a named workflow step (e.g., "Ion beam figuring pass", "Incoming inspection")
|
||||
- system — whole project's system envelope (rare; usually project handles this)
|
||||
|
||||
WHEN TO GRADUATE:
|
||||
|
||||
GRADUATE if the memory clearly names one of these entities with enough detail to be useful. Examples:
|
||||
- "Selected Zerodur for the p04 primary mirror blank" → 2 entities: decision(name="Select Zerodur for primary blank") + material(name="Zerodur")
|
||||
- "ABB Space (INO) is the polishing vendor for p04" → vendor(name="ABB Space")
|
||||
- "Surface figure target is < 25nm RMS after IBF" → requirement(name="Surface figure < 25nm RMS after IBF")
|
||||
- "The Preston model assumes 5N min contact pressure" → parameter(name="Preston min contact pressure = 5N")
|
||||
|
||||
DON'T GRADUATE if the memory is:
|
||||
- A preference or work-style note (those stay as memories)
|
||||
- A session observation ("we tested X today") — no durable typed thing
|
||||
- A general insight / rule of thumb ("Always calibrate before measuring")
|
||||
- An OpenClaw MEMORY.md import of conversational history
|
||||
- Something where you can't pick a clear entity type with confidence
|
||||
|
||||
OUTPUT FORMAT — exactly one JSON object:
|
||||
|
||||
If graduating, emit:
|
||||
{
|
||||
"graduate": true,
|
||||
"entity_type": "component|requirement|decision|...",
|
||||
"name": "short noun phrase, <60 chars",
|
||||
"description": "one-sentence description that adds context beyond the name",
|
||||
"confidence": 0.0-1.0,
|
||||
"relationships": [
|
||||
{"rel_type": "part_of|satisfies|uses_material|based_on_assumption|constrained_by|affected_by_decision|supports|evidenced_by|described_by", "target_hint": "name of the target entity (human will resolve)"}
|
||||
]
|
||||
}
|
||||
|
||||
If not graduating, emit:
|
||||
{"graduate": false, "reason": "one-sentence reason"}
|
||||
|
||||
Rules:
|
||||
- Output ONLY the JSON object, no markdown, no prose
|
||||
- name MUST be <60 chars and specific; reject vague names like "the system"
|
||||
- confidence: 0.6-0.7 is typical. Raise to 0.8+ only if the memory is very specific and unambiguous.
|
||||
- relationships array can be empty
|
||||
- target_hint is a free-text name; the human-review stage will resolve it to an actual entity id (or reject if the target doesn't exist yet)
|
||||
- If the memory describes MULTIPLE entities, pick the single most important one; a second pass can catch the others
|
||||
"""
|
||||
|
||||
|
||||
def build_user_message(memory_content: str, memory_project: str, memory_type: str) -> str:
|
||||
return (
|
||||
f"MEMORY PROJECT: {memory_project or '(unscoped)'}\n"
|
||||
f"MEMORY TYPE: {memory_type}\n\n"
|
||||
f"MEMORY CONTENT:\n{memory_content[:MAX_CONTENT_CHARS]}\n\n"
|
||||
"Return the JSON decision now."
|
||||
)
|
||||
|
||||
|
||||
def parse_graduation_output(raw: str) -> dict[str, Any] | None:
|
||||
"""Parse the LLM's graduation decision. Return None on any parse error.
|
||||
|
||||
On success returns the normalized decision dict with keys:
|
||||
graduate (bool), entity_type (str), name (str), description (str),
|
||||
confidence (float), relationships (list of {rel_type, target_hint})
|
||||
OR {"graduate": false, "reason": "..."}
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
nl = text.find("\n")
|
||||
if nl >= 0:
|
||||
text = text[nl + 1:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
|
||||
# Tolerate leading prose
|
||||
if not text.lstrip().startswith("{"):
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
text = text[start:end + 1]
|
||||
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
|
||||
graduate = bool(parsed.get("graduate", False))
|
||||
if not graduate:
|
||||
return {"graduate": False, "reason": str(parsed.get("reason", ""))[:200]}
|
||||
|
||||
entity_type = str(parsed.get("entity_type") or "").strip().lower()
|
||||
if entity_type not in ENTITY_TYPES:
|
||||
return None
|
||||
|
||||
name = str(parsed.get("name") or "").strip()
|
||||
if not name or len(name) > 120:
|
||||
return None
|
||||
|
||||
description = str(parsed.get("description") or "").strip()[:500]
|
||||
|
||||
try:
|
||||
confidence = float(parsed.get("confidence", 0.6))
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.6
|
||||
confidence = max(0.0, min(1.0, confidence))
|
||||
|
||||
raw_rels = parsed.get("relationships") or []
|
||||
if not isinstance(raw_rels, list):
|
||||
raw_rels = []
|
||||
relationships: list[dict] = []
|
||||
for r in raw_rels[:10]:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
rtype = str(r.get("rel_type") or "").strip().lower()
|
||||
target = str(r.get("target_hint") or "").strip()
|
||||
if not rtype or not target:
|
||||
continue
|
||||
relationships.append({"rel_type": rtype, "target_hint": target[:120]})
|
||||
|
||||
return {
|
||||
"graduate": True,
|
||||
"entity_type": entity_type,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"confidence": confidence,
|
||||
"relationships": relationships,
|
||||
}
|
||||
291
src/atocore/engineering/conflicts.py
Normal file
291
src/atocore/engineering/conflicts.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""Phase 5G — Conflict detection on entity promote.
|
||||
|
||||
When a candidate entity is promoted to active, we check whether another
|
||||
active entity is already claiming the "same slot" with an incompatible
|
||||
value. If so, we emit a conflicts row + conflict_members rows so the
|
||||
human can resolve.
|
||||
|
||||
Slot keys are per-entity-type (from ``conflict-model.md``). V1 starts
|
||||
narrow with 3 slot kinds to avoid false positives:
|
||||
|
||||
1. **component.material** — a component should normally have ONE
|
||||
dominant material (via USES_MATERIAL edge). Two active USES_MATERIAL
|
||||
edges from the same component pointing at different materials =
|
||||
conflict.
|
||||
2. **component.part_of** — a component should belong to AT MOST one
|
||||
subsystem (via PART_OF). Two active PART_OF edges = conflict.
|
||||
3. **requirement.value** — two active Requirements with the same name in
|
||||
the same project but different descriptions = conflict.
|
||||
|
||||
Rule: **flag, never block**. The promote succeeds; the conflict row is
|
||||
just a flag for the human. Users see conflicts in the dashboard and on
|
||||
wiki entity pages with a "⚠️ Disputed" badge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from atocore.models.database import get_connection
|
||||
from atocore.observability.logger import get_logger
|
||||
|
||||
log = get_logger("conflicts")
|
||||
|
||||
|
||||
def detect_conflicts_for_entity(entity_id: str) -> list[str]:
|
||||
"""Run conflict detection for a newly-promoted active entity.
|
||||
|
||||
Returns a list of conflict_ids created. Fail-open: any detection error
|
||||
is logged and returns an empty list; the promote itself is not affected.
|
||||
"""
|
||||
try:
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM entities WHERE id = ? AND status = 'active'",
|
||||
(entity_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return []
|
||||
|
||||
created: list[str] = []
|
||||
etype = row["entity_type"]
|
||||
project = row["project"] or ""
|
||||
|
||||
if etype == "component":
|
||||
created.extend(_check_component_conflicts(entity_id, project))
|
||||
elif etype == "requirement":
|
||||
created.extend(_check_requirement_conflicts(entity_id, row["name"], project))
|
||||
|
||||
return created
|
||||
except Exception as e:
|
||||
log.warning("conflict_detection_failed", entity_id=entity_id, error=str(e))
|
||||
return []
|
||||
|
||||
|
||||
def _check_component_conflicts(component_id: str, project: str) -> list[str]:
|
||||
"""Check material + part_of slot uniqueness for a component."""
|
||||
created: list[str] = []
|
||||
with get_connection() as conn:
|
||||
# component.material conflicts
|
||||
mat_edges = conn.execute(
|
||||
"SELECT r.id AS rel_id, r.target_entity_id, e.name "
|
||||
"FROM relationships r "
|
||||
"JOIN entities e ON e.id = r.target_entity_id "
|
||||
"WHERE r.source_entity_id = ? AND r.relationship_type = 'uses_material' "
|
||||
"AND e.status = 'active'",
|
||||
(component_id,),
|
||||
).fetchall()
|
||||
if len(mat_edges) > 1:
|
||||
cid = _record_conflict(
|
||||
slot_kind="component.material",
|
||||
slot_key=component_id,
|
||||
project=project,
|
||||
note=f"component has {len(mat_edges)} active material edges",
|
||||
members=[
|
||||
{
|
||||
"kind": "entity",
|
||||
"id": m["target_entity_id"],
|
||||
"snapshot": m["name"],
|
||||
}
|
||||
for m in mat_edges
|
||||
],
|
||||
)
|
||||
if cid:
|
||||
created.append(cid)
|
||||
|
||||
# component.part_of conflicts
|
||||
pof_edges = conn.execute(
|
||||
"SELECT r.id AS rel_id, r.target_entity_id, e.name "
|
||||
"FROM relationships r "
|
||||
"JOIN entities e ON e.id = r.target_entity_id "
|
||||
"WHERE r.source_entity_id = ? AND r.relationship_type = 'part_of' "
|
||||
"AND e.status = 'active'",
|
||||
(component_id,),
|
||||
).fetchall()
|
||||
if len(pof_edges) > 1:
|
||||
cid = _record_conflict(
|
||||
slot_kind="component.part_of",
|
||||
slot_key=component_id,
|
||||
project=project,
|
||||
note=f"component is part_of {len(pof_edges)} subsystems",
|
||||
members=[
|
||||
{
|
||||
"kind": "entity",
|
||||
"id": p["target_entity_id"],
|
||||
"snapshot": p["name"],
|
||||
}
|
||||
for p in pof_edges
|
||||
],
|
||||
)
|
||||
if cid:
|
||||
created.append(cid)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def _check_requirement_conflicts(requirement_id: str, name: str, project: str) -> list[str]:
|
||||
"""Two active Requirements with the same name in the same project."""
|
||||
with get_connection() as conn:
|
||||
peers = conn.execute(
|
||||
"SELECT id, description FROM entities "
|
||||
"WHERE entity_type = 'requirement' AND status = 'active' "
|
||||
"AND project = ? AND LOWER(name) = LOWER(?) AND id != ?",
|
||||
(project, name, requirement_id),
|
||||
).fetchall()
|
||||
if not peers:
|
||||
return []
|
||||
|
||||
members = [{"kind": "entity", "id": requirement_id, "snapshot": name}]
|
||||
for p in peers:
|
||||
members.append({"kind": "entity", "id": p["id"],
|
||||
"snapshot": (p["description"] or "")[:200]})
|
||||
|
||||
cid = _record_conflict(
|
||||
slot_kind="requirement.name",
|
||||
slot_key=f"{project}|{name.lower()}",
|
||||
project=project,
|
||||
note=f"{len(peers)+1} active requirements share the name '{name}'",
|
||||
members=members,
|
||||
)
|
||||
return [cid] if cid else []
|
||||
|
||||
|
||||
def _record_conflict(
|
||||
slot_kind: str,
|
||||
slot_key: str,
|
||||
project: str,
|
||||
note: str,
|
||||
members: list[dict],
|
||||
) -> str | None:
|
||||
"""Persist a conflict + its members; skip if an open conflict already
|
||||
exists for the same (slot_kind, slot_key)."""
|
||||
try:
|
||||
with get_connection() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM conflicts WHERE slot_kind = ? AND slot_key = ? "
|
||||
"AND status = 'open'",
|
||||
(slot_kind, slot_key),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return None # don't dup
|
||||
|
||||
conflict_id = str(uuid.uuid4())
|
||||
conn.execute(
|
||||
"INSERT INTO conflicts (id, slot_kind, slot_key, project, "
|
||||
"status, note) VALUES (?, ?, ?, ?, 'open', ?)",
|
||||
(conflict_id, slot_kind, slot_key, project, note[:500]),
|
||||
)
|
||||
for m in members:
|
||||
conn.execute(
|
||||
"INSERT INTO conflict_members (id, conflict_id, member_kind, "
|
||||
"member_id, value_snapshot) VALUES (?, ?, ?, ?, ?)",
|
||||
(str(uuid.uuid4()), conflict_id,
|
||||
m.get("kind", "entity"), m.get("id", ""),
|
||||
(m.get("snapshot") or "")[:500]),
|
||||
)
|
||||
|
||||
log.info("conflict_detected", conflict_id=conflict_id,
|
||||
slot_kind=slot_kind, project=project)
|
||||
|
||||
# Emit a warning alert so the operator sees it
|
||||
try:
|
||||
from atocore.observability.alerts import emit_alert
|
||||
emit_alert(
|
||||
severity="warning",
|
||||
title=f"Entity conflict: {slot_kind}",
|
||||
message=note,
|
||||
context={"project": project, "slot_key": slot_key,
|
||||
"member_count": len(members)},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return conflict_id
|
||||
except Exception as e:
|
||||
log.warning("conflict_record_failed", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
def list_open_conflicts(project: str | None = None) -> list[dict]:
|
||||
"""Return open conflicts with their members."""
|
||||
with get_connection() as conn:
|
||||
query = "SELECT * FROM conflicts WHERE status = 'open'"
|
||||
params: list = []
|
||||
if project:
|
||||
query += " AND project = ?"
|
||||
params.append(project)
|
||||
query += " ORDER BY detected_at DESC"
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
conflicts = []
|
||||
for r in rows:
|
||||
member_rows = conn.execute(
|
||||
"SELECT * FROM conflict_members WHERE conflict_id = ?",
|
||||
(r["id"],),
|
||||
).fetchall()
|
||||
conflicts.append({
|
||||
"id": r["id"],
|
||||
"slot_kind": r["slot_kind"],
|
||||
"slot_key": r["slot_key"],
|
||||
"project": r["project"] or "",
|
||||
"status": r["status"],
|
||||
"note": r["note"] or "",
|
||||
"detected_at": r["detected_at"],
|
||||
"members": [
|
||||
{
|
||||
"id": m["id"],
|
||||
"member_kind": m["member_kind"],
|
||||
"member_id": m["member_id"],
|
||||
"snapshot": m["value_snapshot"] or "",
|
||||
}
|
||||
for m in member_rows
|
||||
],
|
||||
})
|
||||
return conflicts
|
||||
|
||||
|
||||
def resolve_conflict(
|
||||
conflict_id: str,
|
||||
action: str, # "dismiss", "supersede_others", "no_action"
|
||||
winner_id: str | None = None,
|
||||
actor: str = "api",
|
||||
) -> bool:
|
||||
"""Resolve a conflict. Optionally marks non-winner members as superseded."""
|
||||
if action not in ("dismiss", "supersede_others", "no_action"):
|
||||
raise ValueError(f"Invalid action: {action}")
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM conflicts WHERE id = ?", (conflict_id,)
|
||||
).fetchone()
|
||||
if row is None or row["status"] != "open":
|
||||
return False
|
||||
|
||||
if action == "supersede_others":
|
||||
if not winner_id:
|
||||
raise ValueError("winner_id required for supersede_others")
|
||||
# Mark non-winner member entities as superseded
|
||||
member_rows = conn.execute(
|
||||
"SELECT member_id FROM conflict_members WHERE conflict_id = ?",
|
||||
(conflict_id,),
|
||||
).fetchall()
|
||||
for m in member_rows:
|
||||
if m["member_id"] != winner_id:
|
||||
conn.execute(
|
||||
"UPDATE entities SET status = 'superseded', updated_at = ? "
|
||||
"WHERE id = ? AND status = 'active'",
|
||||
(now, m["member_id"]),
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"UPDATE conflicts SET status = 'resolved', resolution = ?, "
|
||||
"resolved_at = ? WHERE id = ?",
|
||||
(action, now, conflict_id),
|
||||
)
|
||||
|
||||
log.info("conflict_resolved", conflict_id=conflict_id,
|
||||
action=action, actor=actor)
|
||||
return True
|
||||
328
src/atocore/engineering/mirror.py
Normal file
328
src/atocore/engineering/mirror.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""Human Mirror — derived readable project views from structured data.
|
||||
|
||||
Layer 3 of the AtoCore architecture. Generates human-readable markdown
|
||||
pages from the engineering entity graph, Trusted Project State, and
|
||||
active memories. These pages are DERIVED — they are not canonical
|
||||
machine truth. They are support surfaces for human inspection and
|
||||
audit comfort.
|
||||
|
||||
The mirror never invents content. Every line traces back to an entity,
|
||||
a state entry, or a memory. If the structured data is wrong, the
|
||||
mirror is wrong — fix the source, not the page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from atocore.context.project_state import get_state
|
||||
from atocore.engineering.service import (
|
||||
get_entities,
|
||||
get_relationships,
|
||||
)
|
||||
from atocore.memory.service import get_memories
|
||||
from atocore.observability.logger import get_logger
|
||||
|
||||
log = get_logger("mirror")
|
||||
|
||||
|
||||
def generate_project_overview(project: str) -> str:
|
||||
"""Generate a full project overview page in markdown."""
|
||||
sections = [
|
||||
_header(project),
|
||||
_synthesis_section(project),
|
||||
_gaps_section(project), # Phase 5: killer queries surface here
|
||||
_state_section(project),
|
||||
_system_architecture(project),
|
||||
_decisions_section(project),
|
||||
_requirements_section(project),
|
||||
_materials_section(project),
|
||||
_vendors_section(project),
|
||||
_active_memories_section(project),
|
||||
_footer(project),
|
||||
]
|
||||
return "\n\n".join(s for s in sections if s)
|
||||
|
||||
|
||||
def _gaps_section(project: str) -> str:
|
||||
"""Phase 5: surface the 3 killer-query gaps on every project page.
|
||||
|
||||
If any gap is non-empty, it appears near the top so the director
|
||||
sees "what am I forgetting?" before the rest of the report.
|
||||
"""
|
||||
try:
|
||||
from atocore.engineering.queries import all_gaps
|
||||
result = all_gaps(project)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
orphan = result["orphan_requirements"]["count"]
|
||||
risky = result["risky_decisions"]["count"]
|
||||
unsup = result["unsupported_claims"]["count"]
|
||||
|
||||
if orphan == 0 and risky == 0 and unsup == 0:
|
||||
return (
|
||||
"## Coverage Gaps\n\n"
|
||||
"> ✅ No gaps detected: every requirement is satisfied, "
|
||||
"no decisions rest on flagged assumptions, every claim has evidence.\n"
|
||||
)
|
||||
|
||||
lines = ["## Coverage Gaps", ""]
|
||||
lines.append(
|
||||
"> ⚠️ Items below need attention — gaps in the engineering graph.\n"
|
||||
)
|
||||
|
||||
if orphan:
|
||||
lines.append(f"### {orphan} Orphan Requirement(s)")
|
||||
lines.append("*Requirements with no component claiming to satisfy them:*")
|
||||
lines.append("")
|
||||
for r in result["orphan_requirements"]["gaps"][:10]:
|
||||
lines.append(f"- **{r['name']}** — {(r['description'] or '')[:120]}")
|
||||
if orphan > 10:
|
||||
lines.append(f"- _...and {orphan - 10} more_")
|
||||
lines.append("")
|
||||
|
||||
if risky:
|
||||
lines.append(f"### {risky} Risky Decision(s)")
|
||||
lines.append("*Decisions based on assumptions that are flagged, superseded, or invalid:*")
|
||||
lines.append("")
|
||||
for d in result["risky_decisions"]["gaps"][:10]:
|
||||
lines.append(
|
||||
f"- **{d['decision_name']}** — based on flagged assumption "
|
||||
f"_{d['assumption_name']}_ ({d['assumption_status']})"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if unsup:
|
||||
lines.append(f"### {unsup} Unsupported Claim(s)")
|
||||
lines.append("*Validation claims with no supporting Result entity:*")
|
||||
lines.append("")
|
||||
for c in result["unsupported_claims"]["gaps"][:10]:
|
||||
lines.append(f"- **{c['name']}** — {(c['description'] or '')[:120]}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _synthesis_section(project: str) -> str:
|
||||
"""Generate a short LLM synthesis of the current project state.
|
||||
|
||||
Reads the cached synthesis from project_state if available
|
||||
(category=status, key=synthesis_cache). If not cached, returns
|
||||
a deterministic summary from the existing structured data.
|
||||
The actual LLM-generated synthesis is produced by the weekly
|
||||
lint/synthesis pass on Dalidou (where claude CLI is available).
|
||||
"""
|
||||
entries = get_state(project)
|
||||
cached = ""
|
||||
for e in entries:
|
||||
if e.category == "status" and e.key == "synthesis_cache":
|
||||
cached = e.value
|
||||
break
|
||||
|
||||
if cached:
|
||||
return f"## Current State (auto-synthesis)\n\n> {cached}"
|
||||
|
||||
# Fallback: deterministic summary from structured data
|
||||
stage = ""
|
||||
summary = ""
|
||||
next_focus = ""
|
||||
for e in entries:
|
||||
if e.category == "status":
|
||||
if e.key == "stage":
|
||||
stage = e.value
|
||||
elif e.key == "summary":
|
||||
summary = e.value
|
||||
elif e.key == "next_focus":
|
||||
next_focus = e.value
|
||||
|
||||
if not (stage or summary or next_focus):
|
||||
return ""
|
||||
|
||||
bits = []
|
||||
if summary:
|
||||
bits.append(summary)
|
||||
if stage:
|
||||
bits.append(f"**Stage**: {stage}")
|
||||
if next_focus:
|
||||
bits.append(f"**Next**: {next_focus}")
|
||||
|
||||
return "## Current State\n\n" + "\n\n".join(bits)
|
||||
|
||||
|
||||
def _header(project: str) -> str:
|
||||
return (
|
||||
f"# {project} — Project Overview\n\n"
|
||||
f"> This page is auto-generated from AtoCore structured data.\n"
|
||||
f"> It is a **derived view**, not canonical truth. "
|
||||
f"If something is wrong here, fix the source data."
|
||||
)
|
||||
|
||||
|
||||
def _state_section(project: str) -> str:
|
||||
entries = get_state(project)
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
lines = ["## Trusted Project State"]
|
||||
by_category: dict[str, list] = {}
|
||||
for e in entries:
|
||||
by_category.setdefault(e.category.upper(), []).append(e)
|
||||
|
||||
for cat in ["DECISION", "REQUIREMENT", "STATUS", "FACT", "MILESTONE", "CONFIG", "CONTACT"]:
|
||||
items = by_category.get(cat, [])
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f"\n### {cat.title()}")
|
||||
for item in items:
|
||||
value = item.value[:300]
|
||||
lines.append(f"- **{item.key}**: {value}")
|
||||
if item.source:
|
||||
lines.append(f" *(source: {item.source})*")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _system_architecture(project: str) -> str:
|
||||
systems = get_entities(entity_type="system", project=project)
|
||||
subsystems = get_entities(entity_type="subsystem", project=project)
|
||||
components = get_entities(entity_type="component", project=project)
|
||||
interfaces = get_entities(entity_type="interface", project=project)
|
||||
|
||||
if not systems and not subsystems and not components:
|
||||
return ""
|
||||
|
||||
lines = ["## System Architecture"]
|
||||
|
||||
for system in systems:
|
||||
lines.append(f"\n### {system.name}")
|
||||
if system.description:
|
||||
lines.append(f"{system.description}")
|
||||
|
||||
rels = get_relationships(system.id, direction="outgoing")
|
||||
children = []
|
||||
for rel in rels:
|
||||
if rel.relationship_type == "contains":
|
||||
child = next(
|
||||
(s for s in subsystems + components if s.id == rel.target_entity_id),
|
||||
None,
|
||||
)
|
||||
if child:
|
||||
children.append(child)
|
||||
|
||||
if children:
|
||||
lines.append("\n**Contains:**")
|
||||
for child in children:
|
||||
desc = f" — {child.description}" if child.description else ""
|
||||
lines.append(f"- [{child.entity_type}] **{child.name}**{desc}")
|
||||
|
||||
child_rels = get_relationships(child.id, direction="both")
|
||||
for cr in child_rels:
|
||||
if cr.relationship_type in ("uses_material", "interfaces_with", "constrained_by"):
|
||||
other_id = (
|
||||
cr.target_entity_id
|
||||
if cr.source_entity_id == child.id
|
||||
else cr.source_entity_id
|
||||
)
|
||||
other = next(
|
||||
(e for e in get_entities(project=project, limit=200)
|
||||
if e.id == other_id),
|
||||
None,
|
||||
)
|
||||
if other:
|
||||
lines.append(
|
||||
f" - *{cr.relationship_type}* → "
|
||||
f"[{other.entity_type}] {other.name}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _decisions_section(project: str) -> str:
|
||||
decisions = get_entities(entity_type="decision", project=project)
|
||||
if not decisions:
|
||||
return ""
|
||||
|
||||
lines = ["## Decisions"]
|
||||
for d in decisions:
|
||||
lines.append(f"\n### {d.name}")
|
||||
if d.description:
|
||||
lines.append(d.description)
|
||||
rels = get_relationships(d.id, direction="outgoing")
|
||||
for rel in rels:
|
||||
if rel.relationship_type == "affected_by_decision":
|
||||
affected = next(
|
||||
(e for e in get_entities(project=project, limit=200)
|
||||
if e.id == rel.target_entity_id),
|
||||
None,
|
||||
)
|
||||
if affected:
|
||||
lines.append(
|
||||
f"- Affects: [{affected.entity_type}] {affected.name}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _requirements_section(project: str) -> str:
|
||||
reqs = get_entities(entity_type="requirement", project=project)
|
||||
constraints = get_entities(entity_type="constraint", project=project)
|
||||
if not reqs and not constraints:
|
||||
return ""
|
||||
|
||||
lines = ["## Requirements & Constraints"]
|
||||
for r in reqs:
|
||||
lines.append(f"- **{r.name}**: {r.description}" if r.description else f"- **{r.name}**")
|
||||
for c in constraints:
|
||||
lines.append(f"- [constraint] **{c.name}**: {c.description}" if c.description else f"- [constraint] **{c.name}**")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _materials_section(project: str) -> str:
|
||||
materials = get_entities(entity_type="material", project=project)
|
||||
if not materials:
|
||||
return ""
|
||||
|
||||
lines = ["## Materials"]
|
||||
for m in materials:
|
||||
desc = f" — {m.description}" if m.description else ""
|
||||
lines.append(f"- **{m.name}**{desc}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _vendors_section(project: str) -> str:
|
||||
vendors = get_entities(entity_type="vendor", project=project)
|
||||
if not vendors:
|
||||
return ""
|
||||
|
||||
lines = ["## Vendors"]
|
||||
for v in vendors:
|
||||
desc = f" — {v.description}" if v.description else ""
|
||||
lines.append(f"- **{v.name}**{desc}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _active_memories_section(project: str) -> str:
|
||||
memories = get_memories(project=project, active_only=True, limit=20)
|
||||
if not memories:
|
||||
return ""
|
||||
|
||||
lines = ["## Active Memories"]
|
||||
for m in memories:
|
||||
conf = f" (conf: {m.confidence:.2f})" if m.confidence < 1.0 else ""
|
||||
refs = f" | refs: {m.reference_count}" if m.reference_count > 0 else ""
|
||||
lines.append(f"- [{m.memory_type}]{conf}{refs} {m.content[:200]}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _footer(project: str) -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
return (
|
||||
f"---\n\n"
|
||||
f"*Generated by AtoCore Human Mirror at {now}. "
|
||||
f"This is a derived view — not canonical truth.*"
|
||||
)
|
||||
467
src/atocore/engineering/queries.py
Normal file
467
src/atocore/engineering/queries.py
Normal file
@@ -0,0 +1,467 @@
|
||||
"""Phase 5 Engineering V1 — The 10 canonical queries.
|
||||
|
||||
Each function maps to one or more catalog IDs in
|
||||
``docs/architecture/engineering-query-catalog.md``. Return values are plain
|
||||
dicts so API and wiki renderers can consume them without importing dataclasses.
|
||||
|
||||
Design principles:
|
||||
- All queries filter to status='active' unless the caller asks otherwise
|
||||
- All project filters go through ``resolve_project_name`` (canonicalization)
|
||||
- Graph traversals are bounded (depth <= 3 for impact, limit 200 for lists)
|
||||
- The 3 "killer" queries (gaps) accept project as required — gaps are always
|
||||
scoped to one project in V1
|
||||
|
||||
These queries are the *useful surface* of the entity graph. Before this module,
|
||||
the graph was data with no narrative; after this module, the director can ask
|
||||
real questions about coverage, risk, and evidence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from atocore.engineering.service import (
|
||||
Entity,
|
||||
_row_to_entity,
|
||||
get_entity,
|
||||
get_relationships,
|
||||
)
|
||||
from atocore.models.database import get_connection
|
||||
from atocore.projects.registry import resolve_project_name
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Structure queries (Q-001, Q-004, Q-005, Q-008)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def system_map(project: str) -> dict:
|
||||
"""Q-001 + Q-004: return the full subsystem/component tree for a project.
|
||||
|
||||
Shape:
|
||||
{
|
||||
"project": "p05-interferometer",
|
||||
"subsystems": [
|
||||
{
|
||||
"id": ..., "name": ..., "description": ...,
|
||||
"components": [{id, name, description, materials: [...]}],
|
||||
},
|
||||
...
|
||||
],
|
||||
"orphan_components": [...], # components with no PART_OF edge
|
||||
}
|
||||
"""
|
||||
project = resolve_project_name(project) if project else ""
|
||||
out: dict = {"project": project, "subsystems": [], "orphan_components": []}
|
||||
|
||||
with get_connection() as conn:
|
||||
# All subsystems in project
|
||||
subsys_rows = conn.execute(
|
||||
"SELECT * FROM entities WHERE status = 'active' "
|
||||
"AND project = ? AND entity_type = 'subsystem' "
|
||||
"ORDER BY name",
|
||||
(project,),
|
||||
).fetchall()
|
||||
|
||||
# All components in project
|
||||
comp_rows = conn.execute(
|
||||
"SELECT * FROM entities WHERE status = 'active' "
|
||||
"AND project = ? AND entity_type = 'component'",
|
||||
(project,),
|
||||
).fetchall()
|
||||
|
||||
# PART_OF edges: component → subsystem
|
||||
part_of_rows = conn.execute(
|
||||
"SELECT source_entity_id, target_entity_id FROM relationships "
|
||||
"WHERE relationship_type = 'part_of'"
|
||||
).fetchall()
|
||||
part_of_map: dict[str, str] = {
|
||||
r["source_entity_id"]: r["target_entity_id"] for r in part_of_rows
|
||||
}
|
||||
|
||||
# uses_material edges for components
|
||||
mat_rows = conn.execute(
|
||||
"SELECT r.source_entity_id, e.name FROM relationships r "
|
||||
"JOIN entities e ON e.id = r.target_entity_id "
|
||||
"WHERE r.relationship_type = 'uses_material' AND e.status = 'active'"
|
||||
).fetchall()
|
||||
materials_by_comp: dict[str, list[str]] = {}
|
||||
for r in mat_rows:
|
||||
materials_by_comp.setdefault(r["source_entity_id"], []).append(r["name"])
|
||||
|
||||
# Build: subsystems → their components
|
||||
subsys_comps: dict[str, list[dict]] = {s["id"]: [] for s in subsys_rows}
|
||||
orphans: list[dict] = []
|
||||
for c in comp_rows:
|
||||
parent = part_of_map.get(c["id"])
|
||||
comp_dict = {
|
||||
"id": c["id"],
|
||||
"name": c["name"],
|
||||
"description": c["description"] or "",
|
||||
"materials": materials_by_comp.get(c["id"], []),
|
||||
}
|
||||
if parent and parent in subsys_comps:
|
||||
subsys_comps[parent].append(comp_dict)
|
||||
else:
|
||||
orphans.append(comp_dict)
|
||||
|
||||
out["subsystems"] = [
|
||||
{
|
||||
"id": s["id"],
|
||||
"name": s["name"],
|
||||
"description": s["description"] or "",
|
||||
"components": subsys_comps.get(s["id"], []),
|
||||
}
|
||||
for s in subsys_rows
|
||||
]
|
||||
out["orphan_components"] = orphans
|
||||
return out
|
||||
|
||||
|
||||
def decisions_affecting(project: str, subsystem_id: str | None = None) -> dict:
|
||||
"""Q-008: decisions that affect a subsystem (or whole project).
|
||||
|
||||
Walks AFFECTED_BY_DECISION edges. If subsystem_id is given, returns
|
||||
decisions linked to that subsystem or any of its components. Otherwise,
|
||||
all decisions in the project.
|
||||
"""
|
||||
project = resolve_project_name(project) if project else ""
|
||||
|
||||
target_ids: set[str] = set()
|
||||
if subsystem_id:
|
||||
target_ids.add(subsystem_id)
|
||||
# Include components PART_OF the subsystem
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT source_entity_id FROM relationships "
|
||||
"WHERE relationship_type = 'part_of' AND target_entity_id = ?",
|
||||
(subsystem_id,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
target_ids.add(r["source_entity_id"])
|
||||
|
||||
with get_connection() as conn:
|
||||
if target_ids:
|
||||
placeholders = ",".join("?" * len(target_ids))
|
||||
rows = conn.execute(
|
||||
f"SELECT DISTINCT e.* FROM entities e "
|
||||
f"JOIN relationships r ON r.source_entity_id = e.id "
|
||||
f"WHERE e.status = 'active' AND e.entity_type = 'decision' "
|
||||
f"AND e.project = ? AND r.relationship_type = 'affected_by_decision' "
|
||||
f"AND r.target_entity_id IN ({placeholders}) "
|
||||
f"ORDER BY e.updated_at DESC",
|
||||
(project, *target_ids),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM entities WHERE status = 'active' "
|
||||
"AND entity_type = 'decision' AND project = ? "
|
||||
"ORDER BY updated_at DESC LIMIT 200",
|
||||
(project,),
|
||||
).fetchall()
|
||||
|
||||
decisions = [_entity_dict(_row_to_entity(r)) for r in rows]
|
||||
return {
|
||||
"project": project,
|
||||
"subsystem_id": subsystem_id or "",
|
||||
"decisions": decisions,
|
||||
"count": len(decisions),
|
||||
}
|
||||
|
||||
|
||||
def requirements_for(component_id: str) -> dict:
|
||||
"""Q-005: requirements that a component satisfies."""
|
||||
with get_connection() as conn:
|
||||
# Component → SATISFIES → Requirement
|
||||
rows = conn.execute(
|
||||
"SELECT e.* FROM entities e "
|
||||
"JOIN relationships r ON r.target_entity_id = e.id "
|
||||
"WHERE r.source_entity_id = ? AND r.relationship_type = 'satisfies' "
|
||||
"AND e.entity_type = 'requirement' AND e.status = 'active' "
|
||||
"ORDER BY e.name",
|
||||
(component_id,),
|
||||
).fetchall()
|
||||
requirements = [_entity_dict(_row_to_entity(r)) for r in rows]
|
||||
return {
|
||||
"component_id": component_id,
|
||||
"requirements": requirements,
|
||||
"count": len(requirements),
|
||||
}
|
||||
|
||||
|
||||
def recent_changes(project: str, since: str | None = None, limit: int = 50) -> dict:
|
||||
"""Q-013: what changed recently in the project (entity audit log).
|
||||
|
||||
Uses the shared memory_audit table filtered by entity_kind='entity' and
|
||||
joins back to entities for the project scope.
|
||||
"""
|
||||
project = resolve_project_name(project) if project else ""
|
||||
since = since or "2020-01-01"
|
||||
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT a.id, a.memory_id AS entity_id, a.action, a.actor, "
|
||||
"a.timestamp, a.note, e.entity_type, e.name, e.project "
|
||||
"FROM memory_audit a "
|
||||
"LEFT JOIN entities e ON e.id = a.memory_id "
|
||||
"WHERE a.entity_kind = 'entity' AND a.timestamp >= ? "
|
||||
"AND (e.project = ? OR e.project IS NULL) "
|
||||
"ORDER BY a.timestamp DESC LIMIT ?",
|
||||
(since, project, limit),
|
||||
).fetchall()
|
||||
|
||||
changes = []
|
||||
for r in rows:
|
||||
changes.append({
|
||||
"audit_id": r["id"],
|
||||
"entity_id": r["entity_id"],
|
||||
"entity_type": r["entity_type"] or "?",
|
||||
"entity_name": r["name"] or "(deleted)",
|
||||
"action": r["action"],
|
||||
"actor": r["actor"] or "api",
|
||||
"note": r["note"] or "",
|
||||
"timestamp": r["timestamp"],
|
||||
})
|
||||
return {"project": project, "since": since, "changes": changes, "count": len(changes)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Killer queries (Q-006, Q-009, Q-011) — the "what am I forgetting?" queries
|
||||
# ============================================================
|
||||
|
||||
|
||||
def orphan_requirements(project: str) -> dict:
|
||||
"""Q-006: requirements in project with NO inbound SATISFIES edge.
|
||||
|
||||
These are "something we said must be true" with nothing actually
|
||||
satisfying them. The single highest-value query for an engineering
|
||||
director: shows what's unclaimed by design.
|
||||
"""
|
||||
project = resolve_project_name(project) if project else ""
|
||||
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM entities WHERE status = 'active' "
|
||||
"AND project = ? AND entity_type = 'requirement' "
|
||||
"AND NOT EXISTS ("
|
||||
" SELECT 1 FROM relationships r "
|
||||
" WHERE r.relationship_type = 'satisfies' "
|
||||
" AND r.target_entity_id = entities.id"
|
||||
") "
|
||||
"ORDER BY updated_at DESC",
|
||||
(project,),
|
||||
).fetchall()
|
||||
|
||||
orphans = [_entity_dict(_row_to_entity(r)) for r in rows]
|
||||
return {
|
||||
"project": project,
|
||||
"query": "Q-006 orphan requirements",
|
||||
"description": "Requirements with no SATISFIES relationship — nothing claims to meet them.",
|
||||
"gaps": orphans,
|
||||
"count": len(orphans),
|
||||
}
|
||||
|
||||
|
||||
def risky_decisions(project: str) -> dict:
|
||||
"""Q-009: decisions linked to assumptions flagged as unresolved.
|
||||
|
||||
Walks BASED_ON_ASSUMPTION edges. An assumption is "flagged" if its
|
||||
properties.flagged=True OR status='superseded' OR status='invalid'.
|
||||
"""
|
||||
project = resolve_project_name(project) if project else ""
|
||||
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT d.*, a.name AS assumption_name, a.id AS assumption_id, "
|
||||
"a.status AS assumption_status, a.properties AS assumption_props "
|
||||
"FROM entities d "
|
||||
"JOIN relationships r ON r.source_entity_id = d.id "
|
||||
"JOIN entities a ON a.id = r.target_entity_id "
|
||||
"WHERE d.status = 'active' AND d.entity_type = 'decision' "
|
||||
"AND d.project = ? "
|
||||
"AND r.relationship_type = 'based_on_assumption' "
|
||||
"AND ("
|
||||
" a.status IN ('superseded', 'invalid') OR "
|
||||
" a.properties LIKE '%\"flagged\": true%' OR "
|
||||
" a.properties LIKE '%\"flagged\":true%'"
|
||||
") "
|
||||
"ORDER BY d.updated_at DESC",
|
||||
(project,),
|
||||
).fetchall()
|
||||
|
||||
risky = []
|
||||
for r in rows:
|
||||
risky.append({
|
||||
"decision_id": r["id"],
|
||||
"decision_name": r["name"],
|
||||
"decision_description": r["description"] or "",
|
||||
"assumption_id": r["assumption_id"],
|
||||
"assumption_name": r["assumption_name"],
|
||||
"assumption_status": r["assumption_status"],
|
||||
})
|
||||
return {
|
||||
"project": project,
|
||||
"query": "Q-009 risky decisions",
|
||||
"description": "Decisions based on assumptions that are flagged, superseded, or invalid.",
|
||||
"gaps": risky,
|
||||
"count": len(risky),
|
||||
}
|
||||
|
||||
|
||||
def unsupported_claims(project: str) -> dict:
|
||||
"""Q-011: validation claims with NO inbound SUPPORTS edge.
|
||||
|
||||
These are asserted claims (e.g., "margin is adequate") with no
|
||||
Result entity actually supporting them. High-risk: the engineer
|
||||
believes it, but there's no evidence on file.
|
||||
"""
|
||||
project = resolve_project_name(project) if project else ""
|
||||
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM entities WHERE status = 'active' "
|
||||
"AND project = ? AND entity_type = 'validation_claim' "
|
||||
"AND NOT EXISTS ("
|
||||
" SELECT 1 FROM relationships r "
|
||||
" WHERE r.relationship_type = 'supports' "
|
||||
" AND r.target_entity_id = entities.id"
|
||||
") "
|
||||
"ORDER BY updated_at DESC",
|
||||
(project,),
|
||||
).fetchall()
|
||||
|
||||
claims = [_entity_dict(_row_to_entity(r)) for r in rows]
|
||||
return {
|
||||
"project": project,
|
||||
"query": "Q-011 unsupported claims",
|
||||
"description": "Validation claims with no supporting Result — asserted but not evidenced.",
|
||||
"gaps": claims,
|
||||
"count": len(claims),
|
||||
}
|
||||
|
||||
|
||||
def all_gaps(project: str) -> dict:
|
||||
"""Combined: run Q-006, Q-009, Q-011 for a project in one go."""
|
||||
return {
|
||||
"project": resolve_project_name(project) if project else "",
|
||||
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"orphan_requirements": orphan_requirements(project),
|
||||
"risky_decisions": risky_decisions(project),
|
||||
"unsupported_claims": unsupported_claims(project),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# History + impact (Q-016, Q-017)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def impact_analysis(entity_id: str, max_depth: int = 3) -> dict:
|
||||
"""Q-016: transitive outbound reach of an entity.
|
||||
|
||||
Walks outbound edges breadth-first to max_depth. Answers "what would
|
||||
be affected if I changed component X?" by finding everything downstream.
|
||||
"""
|
||||
visited: set[str] = {entity_id}
|
||||
impacted: list[dict] = []
|
||||
frontier = [(entity_id, 0)]
|
||||
|
||||
while frontier:
|
||||
current_id, depth = frontier.pop(0)
|
||||
if depth >= max_depth:
|
||||
continue
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT r.relationship_type, r.target_entity_id, "
|
||||
"e.entity_type, e.name, e.status "
|
||||
"FROM relationships r "
|
||||
"JOIN entities e ON e.id = r.target_entity_id "
|
||||
"WHERE r.source_entity_id = ? AND e.status = 'active'",
|
||||
(current_id,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
tid = r["target_entity_id"]
|
||||
if tid in visited:
|
||||
continue
|
||||
visited.add(tid)
|
||||
impacted.append({
|
||||
"entity_id": tid,
|
||||
"entity_type": r["entity_type"],
|
||||
"name": r["name"],
|
||||
"relationship": r["relationship_type"],
|
||||
"depth": depth + 1,
|
||||
})
|
||||
frontier.append((tid, depth + 1))
|
||||
|
||||
root = get_entity(entity_id)
|
||||
return {
|
||||
"root": _entity_dict(root) if root else None,
|
||||
"impacted_count": len(impacted),
|
||||
"impacted": impacted,
|
||||
"max_depth": max_depth,
|
||||
}
|
||||
|
||||
|
||||
def evidence_chain(entity_id: str) -> dict:
|
||||
"""Q-017: what evidence supports this entity?
|
||||
|
||||
Walks inbound SUPPORTS / EVIDENCED_BY / DESCRIBED_BY edges to surface
|
||||
the provenance chain: "this claim is supported by that result, which
|
||||
was produced by that analysis model, which was described by that doc."
|
||||
"""
|
||||
provenance_edges = ("supports", "evidenced_by", "described_by",
|
||||
"validated_by", "analyzed_by")
|
||||
placeholders = ",".join("?" * len(provenance_edges))
|
||||
|
||||
with get_connection() as conn:
|
||||
# Inbound edges of the provenance family
|
||||
inbound_rows = conn.execute(
|
||||
f"SELECT r.relationship_type, r.source_entity_id, "
|
||||
f"e.entity_type, e.name, e.description, e.status "
|
||||
f"FROM relationships r "
|
||||
f"JOIN entities e ON e.id = r.source_entity_id "
|
||||
f"WHERE r.target_entity_id = ? AND e.status = 'active' "
|
||||
f"AND r.relationship_type IN ({placeholders})",
|
||||
(entity_id, *provenance_edges),
|
||||
).fetchall()
|
||||
|
||||
# Also look at source_refs on the entity itself
|
||||
root = get_entity(entity_id)
|
||||
|
||||
chain = []
|
||||
for r in inbound_rows:
|
||||
chain.append({
|
||||
"via": r["relationship_type"],
|
||||
"source_id": r["source_entity_id"],
|
||||
"source_type": r["entity_type"],
|
||||
"source_name": r["name"],
|
||||
"source_description": (r["description"] or "")[:200],
|
||||
})
|
||||
|
||||
return {
|
||||
"root": _entity_dict(root) if root else None,
|
||||
"direct_source_refs": root.source_refs if root else [],
|
||||
"evidence_chain": chain,
|
||||
"count": len(chain),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helpers
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _entity_dict(e: Entity) -> dict:
|
||||
"""Flatten an Entity to a public-API dict."""
|
||||
return {
|
||||
"id": e.id,
|
||||
"entity_type": e.entity_type,
|
||||
"name": e.name,
|
||||
"project": e.project,
|
||||
"description": e.description,
|
||||
"properties": e.properties,
|
||||
"status": e.status,
|
||||
"confidence": e.confidence,
|
||||
"source_refs": e.source_refs,
|
||||
"updated_at": e.updated_at,
|
||||
}
|
||||
575
src/atocore/engineering/service.py
Normal file
575
src/atocore/engineering/service.py
Normal file
@@ -0,0 +1,575 @@
|
||||
"""Engineering entity and relationship CRUD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from atocore.models.database import get_connection
|
||||
from atocore.observability.logger import get_logger
|
||||
from atocore.projects.registry import resolve_project_name
|
||||
|
||||
log = get_logger("engineering")
|
||||
|
||||
ENTITY_TYPES = [
|
||||
"project",
|
||||
"system",
|
||||
"subsystem",
|
||||
"component",
|
||||
"interface",
|
||||
"requirement",
|
||||
"constraint",
|
||||
"decision",
|
||||
"material",
|
||||
"parameter",
|
||||
"analysis_model",
|
||||
"result",
|
||||
"validation_claim",
|
||||
"vendor",
|
||||
"process",
|
||||
]
|
||||
|
||||
RELATIONSHIP_TYPES = [
|
||||
# Structural family
|
||||
"contains",
|
||||
"part_of",
|
||||
"interfaces_with",
|
||||
# Intent family
|
||||
"satisfies",
|
||||
"constrained_by",
|
||||
"affected_by_decision",
|
||||
"based_on_assumption", # Phase 5 — Q-009 killer query
|
||||
"supersedes",
|
||||
# Validation family
|
||||
"analyzed_by",
|
||||
"validated_by",
|
||||
"supports", # Phase 5 — Q-011 killer query
|
||||
"conflicts_with", # Phase 5 — Q-012 future
|
||||
"depends_on",
|
||||
# Provenance family
|
||||
"described_by",
|
||||
"updated_by_session", # Phase 5 — session→entity provenance
|
||||
"evidenced_by", # Phase 5 — Q-017 evidence trace
|
||||
"summarized_in", # Phase 5 — mirror caches
|
||||
# Domain-specific (pre-existing, retained)
|
||||
"uses_material",
|
||||
]
|
||||
|
||||
ENTITY_STATUSES = ["candidate", "active", "superseded", "invalid"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entity:
|
||||
id: str
|
||||
entity_type: str
|
||||
name: str
|
||||
project: str
|
||||
description: str = ""
|
||||
properties: dict = field(default_factory=dict)
|
||||
status: str = "active"
|
||||
confidence: float = 1.0
|
||||
source_refs: list[str] = field(default_factory=list)
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Relationship:
|
||||
id: str
|
||||
source_entity_id: str
|
||||
target_entity_id: str
|
||||
relationship_type: str
|
||||
confidence: float = 1.0
|
||||
source_refs: list[str] = field(default_factory=list)
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
def init_engineering_schema() -> None:
|
||||
with get_connection() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id TEXT PRIMARY KEY,
|
||||
entity_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
project TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
properties TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
source_refs TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS relationships (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_entity_id TEXT NOT NULL,
|
||||
target_entity_id TEXT NOT NULL,
|
||||
relationship_type TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
source_refs TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (source_entity_id) REFERENCES entities(id),
|
||||
FOREIGN KEY (target_entity_id) REFERENCES entities(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_project
|
||||
ON entities(project)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_type
|
||||
ON entities(entity_type)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_relationships_source
|
||||
ON relationships(source_entity_id)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_relationships_target
|
||||
ON relationships(target_entity_id)
|
||||
""")
|
||||
log.info("engineering_schema_initialized")
|
||||
|
||||
|
||||
def create_entity(
|
||||
entity_type: str,
|
||||
name: str,
|
||||
project: str = "",
|
||||
description: str = "",
|
||||
properties: dict | None = None,
|
||||
status: str = "active",
|
||||
confidence: float = 1.0,
|
||||
source_refs: list[str] | None = None,
|
||||
actor: str = "api",
|
||||
) -> Entity:
|
||||
if entity_type not in ENTITY_TYPES:
|
||||
raise ValueError(f"Invalid entity type: {entity_type}. Must be one of {ENTITY_TYPES}")
|
||||
if status not in ENTITY_STATUSES:
|
||||
raise ValueError(f"Invalid status: {status}. Must be one of {ENTITY_STATUSES}")
|
||||
if not name or not name.strip():
|
||||
raise ValueError("Entity name must be non-empty")
|
||||
|
||||
# Phase 5: enforce project canonicalization contract at the write seam.
|
||||
# Aliases like "p04" become "p04-gigabit" so downstream reads stay
|
||||
# consistent with the registry.
|
||||
project = resolve_project_name(project) if project else ""
|
||||
|
||||
entity_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
props = properties or {}
|
||||
refs = source_refs or []
|
||||
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO entities
|
||||
(id, entity_type, name, project, description, properties,
|
||||
status, confidence, source_refs, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
entity_id, entity_type, name.strip(), project,
|
||||
description, json.dumps(props), status, confidence,
|
||||
json.dumps(refs), now, now,
|
||||
),
|
||||
)
|
||||
|
||||
log.info("entity_created", entity_id=entity_id, entity_type=entity_type, name=name)
|
||||
|
||||
# Phase 5: entity audit rows share the memory_audit table via
|
||||
# entity_kind="entity" discriminator. Same infrastructure, unified history.
|
||||
_audit_entity(
|
||||
entity_id=entity_id,
|
||||
action="created",
|
||||
actor=actor,
|
||||
after={
|
||||
"entity_type": entity_type,
|
||||
"name": name.strip(),
|
||||
"project": project,
|
||||
"status": status,
|
||||
"confidence": confidence,
|
||||
},
|
||||
)
|
||||
|
||||
return Entity(
|
||||
id=entity_id, entity_type=entity_type, name=name.strip(),
|
||||
project=project, description=description, properties=props,
|
||||
status=status, confidence=confidence, source_refs=refs,
|
||||
created_at=now, updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
def _audit_entity(
|
||||
entity_id: str,
|
||||
action: str,
|
||||
actor: str = "api",
|
||||
before: dict | None = None,
|
||||
after: dict | None = None,
|
||||
note: str = "",
|
||||
) -> None:
|
||||
"""Append an entity mutation row to the shared memory_audit table."""
|
||||
try:
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO memory_audit (id, memory_id, action, actor, "
|
||||
"before_json, after_json, note, entity_kind) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, 'entity')",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
entity_id,
|
||||
action,
|
||||
actor or "api",
|
||||
json.dumps(before or {}),
|
||||
json.dumps(after or {}),
|
||||
(note or "")[:500],
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning("entity_audit_failed", entity_id=entity_id, action=action, error=str(e))
|
||||
|
||||
|
||||
def create_relationship(
|
||||
source_entity_id: str,
|
||||
target_entity_id: str,
|
||||
relationship_type: str,
|
||||
confidence: float = 1.0,
|
||||
source_refs: list[str] | None = None,
|
||||
) -> Relationship:
|
||||
if relationship_type not in RELATIONSHIP_TYPES:
|
||||
raise ValueError(f"Invalid relationship type: {relationship_type}")
|
||||
|
||||
rel_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
refs = source_refs or []
|
||||
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO relationships
|
||||
(id, source_entity_id, target_entity_id, relationship_type,
|
||||
confidence, source_refs, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(rel_id, source_entity_id, target_entity_id,
|
||||
relationship_type, confidence, json.dumps(refs), now),
|
||||
)
|
||||
|
||||
log.info(
|
||||
"relationship_created",
|
||||
rel_id=rel_id,
|
||||
source=source_entity_id,
|
||||
target=target_entity_id,
|
||||
rel_type=relationship_type,
|
||||
)
|
||||
# Phase 5: relationship audit as an entity action on the source
|
||||
_audit_entity(
|
||||
entity_id=source_entity_id,
|
||||
action="relationship_added",
|
||||
actor="api",
|
||||
after={
|
||||
"rel_id": rel_id,
|
||||
"rel_type": relationship_type,
|
||||
"target": target_entity_id,
|
||||
},
|
||||
)
|
||||
return Relationship(
|
||||
id=rel_id, source_entity_id=source_entity_id,
|
||||
target_entity_id=target_entity_id,
|
||||
relationship_type=relationship_type,
|
||||
confidence=confidence, source_refs=refs, created_at=now,
|
||||
)
|
||||
|
||||
|
||||
# --- Phase 5: Entity promote/reject lifecycle ---
|
||||
|
||||
|
||||
def _set_entity_status(
|
||||
entity_id: str,
|
||||
new_status: str,
|
||||
actor: str = "api",
|
||||
note: str = "",
|
||||
) -> bool:
|
||||
"""Transition an entity's status with audit."""
|
||||
if new_status not in ENTITY_STATUSES:
|
||||
raise ValueError(f"Invalid status: {new_status}")
|
||||
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT status FROM entities WHERE id = ?", (entity_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
old_status = row["status"]
|
||||
if old_status == new_status:
|
||||
return False
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"UPDATE entities SET status = ?, updated_at = ? WHERE id = ?",
|
||||
(new_status, now, entity_id),
|
||||
)
|
||||
|
||||
# Action verb mirrors memory pattern
|
||||
if new_status == "active" and old_status == "candidate":
|
||||
action = "promoted"
|
||||
elif new_status == "invalid" and old_status == "candidate":
|
||||
action = "rejected"
|
||||
elif new_status == "invalid":
|
||||
action = "invalidated"
|
||||
elif new_status == "superseded":
|
||||
action = "superseded"
|
||||
else:
|
||||
action = "status_changed"
|
||||
|
||||
_audit_entity(
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
actor=actor,
|
||||
before={"status": old_status},
|
||||
after={"status": new_status},
|
||||
note=note,
|
||||
)
|
||||
log.info("entity_status_changed", entity_id=entity_id,
|
||||
old=old_status, new=new_status, action=action)
|
||||
return True
|
||||
|
||||
|
||||
def promote_entity(entity_id: str, actor: str = "api", note: str = "") -> bool:
|
||||
"""Promote a candidate entity to active.
|
||||
|
||||
Phase 5F graduation hook: if this entity has source_refs pointing at
|
||||
memories (format "memory:<uuid>"), mark those source memories as
|
||||
``status=graduated`` and set their ``graduated_to_entity_id`` forward
|
||||
pointer. This preserves the memory as an immutable historical record
|
||||
while signalling that it's been absorbed into the typed graph.
|
||||
"""
|
||||
entity = get_entity(entity_id)
|
||||
if entity is None or entity.status != "candidate":
|
||||
return False
|
||||
|
||||
ok = _set_entity_status(entity_id, "active", actor=actor, note=note)
|
||||
if not ok:
|
||||
return False
|
||||
|
||||
# Phase 5F: mark source memories as graduated
|
||||
memory_ids = [
|
||||
ref.split(":", 1)[1]
|
||||
for ref in (entity.source_refs or [])
|
||||
if isinstance(ref, str) and ref.startswith("memory:")
|
||||
]
|
||||
if memory_ids:
|
||||
_graduate_source_memories(memory_ids, entity_id, actor=actor)
|
||||
|
||||
# Phase 5G: sync conflict detection on promote. Fail-open — detection
|
||||
# errors log but never undo the successful promote.
|
||||
try:
|
||||
from atocore.engineering.conflicts import detect_conflicts_for_entity
|
||||
detect_conflicts_for_entity(entity_id)
|
||||
except Exception as e:
|
||||
log.warning("conflict_detection_failed", entity_id=entity_id, error=str(e))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _graduate_source_memories(memory_ids: list[str], entity_id: str, actor: str) -> None:
|
||||
"""Mark source memories as graduated and set forward pointer."""
|
||||
if not memory_ids:
|
||||
return
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with get_connection() as conn:
|
||||
for mid in memory_ids:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT status FROM memories WHERE id = ?", (mid,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
continue
|
||||
old_status = row["status"]
|
||||
if old_status == "graduated":
|
||||
continue # already graduated — maybe by a different entity
|
||||
conn.execute(
|
||||
"UPDATE memories SET status = 'graduated', "
|
||||
"graduated_to_entity_id = ?, updated_at = ? WHERE id = ?",
|
||||
(entity_id, now, mid),
|
||||
)
|
||||
# Write a memory_audit row for the graduation
|
||||
conn.execute(
|
||||
"INSERT INTO memory_audit (id, memory_id, action, actor, "
|
||||
"before_json, after_json, note, entity_kind) "
|
||||
"VALUES (?, ?, 'graduated', ?, ?, ?, ?, 'memory')",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
mid,
|
||||
actor or "api",
|
||||
json.dumps({"status": old_status}),
|
||||
json.dumps({
|
||||
"status": "graduated",
|
||||
"graduated_to_entity_id": entity_id,
|
||||
}),
|
||||
f"graduated to entity {entity_id[:8]}",
|
||||
),
|
||||
)
|
||||
log.info("memory_graduated", memory_id=mid,
|
||||
entity_id=entity_id, old_status=old_status)
|
||||
except Exception as e:
|
||||
log.warning("memory_graduation_failed",
|
||||
memory_id=mid, entity_id=entity_id, error=str(e))
|
||||
|
||||
|
||||
def reject_entity_candidate(entity_id: str, actor: str = "api", note: str = "") -> bool:
|
||||
"""Reject a candidate entity (status → invalid)."""
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT status FROM entities WHERE id = ?", (entity_id,)
|
||||
).fetchone()
|
||||
if row is None or row["status"] != "candidate":
|
||||
return False
|
||||
return _set_entity_status(entity_id, "invalid", actor=actor, note=note)
|
||||
|
||||
|
||||
def supersede_entity(entity_id: str, actor: str = "api", note: str = "") -> bool:
|
||||
"""Mark an active entity as superseded by a newer one."""
|
||||
return _set_entity_status(entity_id, "superseded", actor=actor, note=note)
|
||||
|
||||
|
||||
def get_entity_audit(entity_id: str, limit: int = 100) -> list[dict]:
|
||||
"""Fetch audit entries for an entity from the shared audit table."""
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, memory_id AS entity_id, action, actor, before_json, "
|
||||
"after_json, note, timestamp FROM memory_audit "
|
||||
"WHERE entity_kind = 'entity' AND memory_id = ? "
|
||||
"ORDER BY timestamp DESC LIMIT ?",
|
||||
(entity_id, limit),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
try:
|
||||
before = json.loads(r["before_json"] or "{}")
|
||||
except Exception:
|
||||
before = {}
|
||||
try:
|
||||
after = json.loads(r["after_json"] or "{}")
|
||||
except Exception:
|
||||
after = {}
|
||||
out.append({
|
||||
"id": r["id"],
|
||||
"entity_id": r["entity_id"],
|
||||
"action": r["action"],
|
||||
"actor": r["actor"] or "api",
|
||||
"before": before,
|
||||
"after": after,
|
||||
"note": r["note"] or "",
|
||||
"timestamp": r["timestamp"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def get_entities(
|
||||
entity_type: str | None = None,
|
||||
project: str | None = None,
|
||||
status: str = "active",
|
||||
name_contains: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[Entity]:
|
||||
query = "SELECT * FROM entities WHERE status = ?"
|
||||
params: list = [status]
|
||||
|
||||
if entity_type:
|
||||
query += " AND entity_type = ?"
|
||||
params.append(entity_type)
|
||||
if project is not None:
|
||||
query += " AND project = ?"
|
||||
params.append(project)
|
||||
if name_contains:
|
||||
query += " AND name LIKE ?"
|
||||
params.append(f"%{name_contains}%")
|
||||
|
||||
query += " ORDER BY entity_type, name LIMIT ?"
|
||||
params.append(min(limit, 500))
|
||||
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [_row_to_entity(r) for r in rows]
|
||||
|
||||
|
||||
def get_entity(entity_id: str) -> Entity | None:
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM entities WHERE id = ?", (entity_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_entity(row)
|
||||
|
||||
|
||||
def get_relationships(
|
||||
entity_id: str,
|
||||
direction: str = "both",
|
||||
) -> list[Relationship]:
|
||||
results = []
|
||||
with get_connection() as conn:
|
||||
if direction in ("outgoing", "both"):
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM relationships WHERE source_entity_id = ?",
|
||||
(entity_id,),
|
||||
).fetchall()
|
||||
results.extend(_row_to_relationship(r) for r in rows)
|
||||
if direction in ("incoming", "both"):
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM relationships WHERE target_entity_id = ?",
|
||||
(entity_id,),
|
||||
).fetchall()
|
||||
results.extend(_row_to_relationship(r) for r in rows)
|
||||
return results
|
||||
|
||||
|
||||
def get_entity_with_context(entity_id: str) -> dict | None:
|
||||
entity = get_entity(entity_id)
|
||||
if entity is None:
|
||||
return None
|
||||
relationships = get_relationships(entity_id)
|
||||
related_ids = set()
|
||||
for rel in relationships:
|
||||
related_ids.add(rel.source_entity_id)
|
||||
related_ids.add(rel.target_entity_id)
|
||||
related_ids.discard(entity_id)
|
||||
|
||||
related_entities = {}
|
||||
for rid in related_ids:
|
||||
e = get_entity(rid)
|
||||
if e:
|
||||
related_entities[rid] = e
|
||||
|
||||
return {
|
||||
"entity": entity,
|
||||
"relationships": relationships,
|
||||
"related_entities": related_entities,
|
||||
}
|
||||
|
||||
|
||||
def _row_to_entity(row) -> Entity:
|
||||
return Entity(
|
||||
id=row["id"],
|
||||
entity_type=row["entity_type"],
|
||||
name=row["name"],
|
||||
project=row["project"] or "",
|
||||
description=row["description"] or "",
|
||||
properties=json.loads(row["properties"] or "{}"),
|
||||
status=row["status"],
|
||||
confidence=row["confidence"],
|
||||
source_refs=json.loads(row["source_refs"] or "[]"),
|
||||
created_at=row["created_at"] or "",
|
||||
updated_at=row["updated_at"] or "",
|
||||
)
|
||||
|
||||
|
||||
def _row_to_relationship(row) -> Relationship:
|
||||
return Relationship(
|
||||
id=row["id"],
|
||||
source_entity_id=row["source_entity_id"],
|
||||
target_entity_id=row["target_entity_id"],
|
||||
relationship_type=row["relationship_type"],
|
||||
confidence=row["confidence"],
|
||||
source_refs=json.loads(row["source_refs"] or "[]"),
|
||||
created_at=row["created_at"] or "",
|
||||
)
|
||||
747
src/atocore/engineering/triage_ui.py
Normal file
747
src/atocore/engineering/triage_ui.py
Normal file
@@ -0,0 +1,747 @@
|
||||
"""Human triage UI for AtoCore candidate memories.
|
||||
|
||||
Renders a lightweight HTML page at /admin/triage with all pending
|
||||
candidate memories, each with inline Promote / Reject / Edit buttons.
|
||||
No framework, no JS build, no database — reads candidates from the
|
||||
AtoCore DB and posts back to the existing REST endpoints.
|
||||
|
||||
Design principle: the user should be able to triage 20 candidates in
|
||||
60 seconds from any browser. Keyboard shortcuts (y/n/e/s) make it
|
||||
feel like email triage (archive/delete).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _html
|
||||
|
||||
from atocore.engineering.wiki import render_html
|
||||
from atocore.memory.service import get_memories
|
||||
|
||||
|
||||
VALID_TYPES = ["identity", "preference", "project", "episodic", "knowledge", "adaptation"]
|
||||
|
||||
|
||||
def _escape(s: str | None) -> str:
|
||||
return _html.escape(s or "", quote=True)
|
||||
|
||||
|
||||
def _render_candidate_card(cand) -> str:
|
||||
"""One candidate row with inline forms for promote/reject/edit."""
|
||||
mid = _escape(cand.id)
|
||||
content = _escape(cand.content)
|
||||
memory_type = _escape(cand.memory_type)
|
||||
project = _escape(cand.project or "")
|
||||
project_display = project or "(global)"
|
||||
confidence = f"{cand.confidence:.2f}"
|
||||
refs = cand.reference_count or 0
|
||||
created = _escape(str(cand.created_at or ""))
|
||||
tags = cand.domain_tags or []
|
||||
tags_str = _escape(", ".join(tags))
|
||||
valid_until = _escape(cand.valid_until or "")
|
||||
# Strip time portion for HTML date input
|
||||
valid_until_date = valid_until[:10] if valid_until else ""
|
||||
|
||||
type_options = "".join(
|
||||
f'<option value="{t}"{" selected" if t == cand.memory_type else ""}>{t}</option>'
|
||||
for t in VALID_TYPES
|
||||
)
|
||||
|
||||
# Tag badges rendered from current tags
|
||||
badges_html = ""
|
||||
if tags:
|
||||
badges_html = '<div class="cand-tags-display">' + "".join(
|
||||
f'<span class="tag-badge">{_escape(t)}</span>' for t in tags
|
||||
) + '</div>'
|
||||
|
||||
return f"""
|
||||
<div class="cand" id="cand-{mid}" data-id="{mid}">
|
||||
<div class="cand-head">
|
||||
<span class="cand-type">[{memory_type}]</span>
|
||||
<span class="cand-project">{project_display}</span>
|
||||
<span class="cand-meta">conf {confidence} · refs {refs} · {created[:16]}</span>
|
||||
</div>
|
||||
<div class="cand-body">
|
||||
<textarea class="cand-content" id="content-{mid}">{content}</textarea>
|
||||
</div>
|
||||
{badges_html}
|
||||
<div class="cand-meta-row">
|
||||
<label class="cand-field-label">Tags:
|
||||
<input type="text" class="cand-tags-input" id="tags-{mid}"
|
||||
value="{tags_str}" placeholder="optics, thermal, p04" />
|
||||
</label>
|
||||
<label class="cand-field-label">Valid until:
|
||||
<input type="date" class="cand-valid-until" id="valid-until-{mid}"
|
||||
value="{valid_until_date}" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="cand-actions">
|
||||
<button class="btn-promote" data-id="{mid}" title="Promote (Y)">✅ Promote</button>
|
||||
<button class="btn-reject" data-id="{mid}" title="Reject (N)">❌ Reject</button>
|
||||
<button class="btn-save-promote" data-id="{mid}" title="Save edits + promote (E)">✏️ Save&Promote</button>
|
||||
<label class="cand-type-label">Type:
|
||||
<select class="cand-type-select" id="type-{mid}">{type_options}</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="cand-status" id="status-{mid}"></div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
_TRIAGE_SCRIPT = """
|
||||
<script>
|
||||
async function apiCall(url, method, body) {
|
||||
try {
|
||||
const opts = { method };
|
||||
if (body) {
|
||||
opts.headers = { 'Content-Type': 'application/json' };
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
return { ok: res.ok, status: res.status, json: res.ok ? await res.json().catch(()=>null) : null };
|
||||
} catch (e) { return { ok: false, status: 0, error: String(e) }; }
|
||||
}
|
||||
|
||||
async function requestAutoTriage() {
|
||||
const btn = document.getElementById('auto-triage-btn');
|
||||
const status = document.getElementById('auto-triage-status');
|
||||
if (!btn) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Requesting...';
|
||||
const r = await apiCall('/admin/triage/request-drain', 'POST');
|
||||
if (r.ok) {
|
||||
status.textContent = '✓ Requested. Host watcher runs every 2 min. Refresh this page in a minute to check progress.';
|
||||
status.className = 'auto-triage-msg ok';
|
||||
btn.textContent = '✓ Requested';
|
||||
pollDrainStatus();
|
||||
} else {
|
||||
status.textContent = '❌ Request failed: ' + r.status;
|
||||
status.className = 'auto-triage-msg err';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🤖 Auto-process queue';
|
||||
}
|
||||
}
|
||||
|
||||
async function pollDrainStatus() {
|
||||
const status = document.getElementById('auto-triage-status');
|
||||
const btn = document.getElementById('auto-triage-btn');
|
||||
let polls = 0;
|
||||
const timer = setInterval(async () => {
|
||||
polls++;
|
||||
const r = await apiCall('/admin/triage/drain-status', 'GET');
|
||||
if (!r.ok || !r.json) return;
|
||||
const s = r.json;
|
||||
if (s.is_running) {
|
||||
status.textContent = '⚙️ Auto-triage running on host... (started ' + (s.last_started_at || '?') + ')';
|
||||
status.className = 'auto-triage-msg ok';
|
||||
} else if (s.last_finished_at && !s.requested_at) {
|
||||
status.textContent = '✅ Last run finished: ' + s.last_finished_at + ' → ' + (s.last_result || 'complete');
|
||||
status.className = 'auto-triage-msg ok';
|
||||
if (btn) { btn.disabled = false; btn.textContent = '🤖 Auto-process queue'; }
|
||||
clearInterval(timer);
|
||||
// Reload page to pick up new queue state
|
||||
setTimeout(() => window.location.reload(), 3000);
|
||||
}
|
||||
if (polls > 60) { clearInterval(timer); } // stop after ~10 min of polling
|
||||
}, 10000); // poll every 10s
|
||||
}
|
||||
|
||||
function setStatus(id, msg, ok) {
|
||||
const el = document.getElementById('status-' + id);
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = 'cand-status ' + (ok ? 'ok' : 'err');
|
||||
}
|
||||
|
||||
function removeCard(id) {
|
||||
setTimeout(() => {
|
||||
const card = document.getElementById('cand-' + id);
|
||||
if (card) {
|
||||
card.style.opacity = '0';
|
||||
setTimeout(() => card.remove(), 300);
|
||||
}
|
||||
updateCount();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
const n = document.querySelectorAll('.cand').length;
|
||||
const el = document.getElementById('cand-count');
|
||||
if (el) el.textContent = n;
|
||||
const next = document.querySelector('.cand');
|
||||
if (next) next.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
async function promote(id) {
|
||||
setStatus(id, 'Promoting…', true);
|
||||
const r = await apiCall('/memory/' + encodeURIComponent(id) + '/promote', 'POST');
|
||||
if (r.ok) { setStatus(id, '✅ Promoted', true); removeCard(id); }
|
||||
else setStatus(id, '❌ Failed: ' + r.status, false);
|
||||
}
|
||||
|
||||
async function reject(id) {
|
||||
setStatus(id, 'Rejecting…', true);
|
||||
const r = await apiCall('/memory/' + encodeURIComponent(id) + '/reject', 'POST');
|
||||
if (r.ok) { setStatus(id, '❌ Rejected', true); removeCard(id); }
|
||||
else setStatus(id, '❌ Failed: ' + r.status, false);
|
||||
}
|
||||
|
||||
function parseTags(str) {
|
||||
return (str || '').split(/[,;]/).map(s => s.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function savePromote(id) {
|
||||
const content = document.getElementById('content-' + id).value.trim();
|
||||
const mtype = document.getElementById('type-' + id).value;
|
||||
const tagsStr = document.getElementById('tags-' + id)?.value || '';
|
||||
const validUntil = document.getElementById('valid-until-' + id)?.value || '';
|
||||
if (!content) { setStatus(id, 'Content is empty', false); return; }
|
||||
setStatus(id, 'Saving…', true);
|
||||
const body = {
|
||||
content: content,
|
||||
memory_type: mtype,
|
||||
domain_tags: parseTags(tagsStr),
|
||||
valid_until: validUntil,
|
||||
};
|
||||
const r1 = await apiCall('/memory/' + encodeURIComponent(id), 'PUT', body);
|
||||
if (!r1.ok) { setStatus(id, '❌ Save failed: ' + r1.status, false); return; }
|
||||
const r2 = await apiCall('/memory/' + encodeURIComponent(id) + '/promote', 'POST');
|
||||
if (r2.ok) { setStatus(id, '✅ Saved & Promoted', true); removeCard(id); }
|
||||
else setStatus(id, '❌ Promote failed: ' + r2.status, false);
|
||||
}
|
||||
|
||||
// Also save tag/expiry edits when plain "Promote" is clicked if fields changed
|
||||
async function promoteWithMeta(id) {
|
||||
const tagsStr = document.getElementById('tags-' + id)?.value || '';
|
||||
const validUntil = document.getElementById('valid-until-' + id)?.value || '';
|
||||
if (tagsStr.trim() || validUntil) {
|
||||
await apiCall('/memory/' + encodeURIComponent(id), 'PUT', {
|
||||
domain_tags: parseTags(tagsStr),
|
||||
valid_until: validUntil,
|
||||
});
|
||||
}
|
||||
return promote(id);
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const id = e.target.dataset?.id;
|
||||
if (!id) return;
|
||||
if (e.target.classList.contains('btn-promote')) promoteWithMeta(id);
|
||||
else if (e.target.classList.contains('btn-reject')) reject(id);
|
||||
else if (e.target.classList.contains('btn-save-promote')) savePromote(id);
|
||||
});
|
||||
|
||||
// Keyboard shortcuts on the currently-focused card
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Don't intercept if user is typing in textarea/select/input
|
||||
const t = e.target.tagName;
|
||||
if (t === 'TEXTAREA' || t === 'INPUT' || t === 'SELECT') return;
|
||||
const first = document.querySelector('.cand');
|
||||
if (!first) return;
|
||||
const id = first.dataset.id;
|
||||
if (e.key === 'y' || e.key === 'Y') { e.preventDefault(); promoteWithMeta(id); }
|
||||
else if (e.key === 'n' || e.key === 'N') { e.preventDefault(); reject(id); }
|
||||
else if (e.key === 'e' || e.key === 'E') {
|
||||
e.preventDefault();
|
||||
document.getElementById('content-' + id)?.focus();
|
||||
}
|
||||
else if (e.key === 's' || e.key === 'S') { e.preventDefault(); first.scrollIntoView({behavior:'smooth'}); }
|
||||
});
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
_TRIAGE_CSS = """
|
||||
<style>
|
||||
.triage-header { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:1rem; }
|
||||
.triage-header .count { font-size:1.4rem; font-weight:600; color:var(--accent); }
|
||||
.triage-help { background:var(--card); border-left:4px solid var(--accent); padding:0.8rem 1rem; margin-bottom:1.5rem; border-radius:4px; font-size:0.9rem; }
|
||||
.triage-help kbd { background:var(--hover); padding:2px 6px; border-radius:3px; font-family:monospace; font-size:0.85em; border:1px solid var(--border); }
|
||||
.cand { background:var(--card); border:1px solid var(--border); border-radius:6px; padding:1rem; margin-bottom:1rem; transition:opacity 0.3s; }
|
||||
.cand-head { display:flex; gap:0.8rem; align-items:center; margin-bottom:0.6rem; font-size:0.9rem; }
|
||||
.cand-type { font-weight:600; color:var(--accent); font-family:monospace; }
|
||||
.cand-project { color:var(--text); opacity:0.8; font-family:monospace; }
|
||||
.cand-meta { color:var(--text); opacity:0.55; font-size:0.8rem; margin-left:auto; }
|
||||
.cand-content { width:100%; min-height:80px; font-family:inherit; font-size:0.95rem; padding:0.5rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:4px; resize:vertical; box-sizing:border-box; }
|
||||
.cand-content:focus { outline:none; border-color:var(--accent); }
|
||||
.cand-actions { display:flex; gap:0.5rem; margin-top:0.8rem; align-items:center; flex-wrap:wrap; }
|
||||
.cand-actions button { padding:0.4rem 0.9rem; border:1px solid var(--border); background:var(--card); color:var(--text); border-radius:4px; cursor:pointer; font-size:0.88rem; }
|
||||
.cand-actions button:hover { background:var(--hover); }
|
||||
.btn-promote:hover { background:#059669; color:white; border-color:#059669; }
|
||||
.btn-reject:hover { background:#dc2626; color:white; border-color:#dc2626; }
|
||||
.btn-save-promote:hover { background:var(--accent); color:white; border-color:var(--accent); }
|
||||
.cand-type-label { font-size:0.85rem; margin-left:auto; opacity:0.7; }
|
||||
.cand-type-select { padding:0.25rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; font-family:monospace; }
|
||||
.cand-status { margin-top:0.5rem; font-size:0.85rem; min-height:1.2em; }
|
||||
.cand-status.ok { color:#059669; }
|
||||
.cand-status.err { color:#dc2626; }
|
||||
.empty { text-align:center; padding:3rem; opacity:0.6; }
|
||||
.auto-triage-bar { display:flex; gap:0.8rem; align-items:center; background:var(--card); border:1px solid var(--border); border-radius:6px; padding:0.7rem 1rem; margin-bottom:1.2rem; flex-wrap:wrap; }
|
||||
.auto-triage-bar button { padding:0.55rem 1.1rem; border:1px solid var(--accent); background:var(--accent); color:white; border-radius:4px; cursor:pointer; font-weight:600; font-size:0.95rem; }
|
||||
.auto-triage-bar button:hover:not(:disabled) { opacity:0.9; }
|
||||
.auto-triage-bar button:disabled { opacity:0.5; cursor:not-allowed; }
|
||||
.auto-triage-msg { flex:1; min-width:200px; font-size:0.85rem; opacity:0.75; }
|
||||
.auto-triage-msg.ok { color:var(--accent); opacity:1; font-weight:500; }
|
||||
.auto-triage-msg.err { color:#dc2626; opacity:1; font-weight:500; }
|
||||
.cand-tags-display { margin-top:0.5rem; display:flex; gap:0.35rem; flex-wrap:wrap; }
|
||||
.tag-badge { background:var(--accent); color:white; padding:0.15rem 0.55rem; border-radius:10px; font-size:0.72rem; font-family:monospace; font-weight:500; }
|
||||
.cand-meta-row { display:flex; gap:0.8rem; margin-top:0.6rem; align-items:center; flex-wrap:wrap; }
|
||||
.cand-field-label { display:flex; gap:0.3rem; align-items:center; font-size:0.85rem; opacity:0.75; }
|
||||
.cand-tags-input { flex:1; min-width:200px; padding:0.3rem 0.5rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; font-family:monospace; font-size:0.85rem; }
|
||||
.cand-tags-input:focus { outline:none; border-color:var(--accent); }
|
||||
.cand-valid-until { padding:0.3rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px; font-family:inherit; font-size:0.85rem; }
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
def _render_entity_card(entity) -> str:
|
||||
"""Phase 5: entity candidate card with promote/reject."""
|
||||
eid = _escape(entity.id)
|
||||
name = _escape(entity.name)
|
||||
etype = _escape(entity.entity_type)
|
||||
project = _escape(entity.project or "(global)")
|
||||
desc = _escape(entity.description or "")
|
||||
conf = f"{entity.confidence:.2f}"
|
||||
src_refs = entity.source_refs or []
|
||||
source_display = _escape(", ".join(src_refs[:3])) if src_refs else "(no provenance)"
|
||||
|
||||
return f"""
|
||||
<div class="cand cand-entity" id="ecand-{eid}" data-entity-id="{eid}">
|
||||
<div class="cand-head">
|
||||
<span class="cand-type entity-type">[entity · {etype}]</span>
|
||||
<span class="cand-project">{project}</span>
|
||||
<span class="cand-meta">conf {conf} · src: {source_display}</span>
|
||||
</div>
|
||||
<div class="cand-body">
|
||||
<div class="entity-name">{name}</div>
|
||||
<div class="entity-desc">{desc}</div>
|
||||
</div>
|
||||
<div class="cand-actions">
|
||||
<button class="btn-entity-promote" data-entity-id="{eid}" title="Promote entity (Y)">✅ Promote Entity</button>
|
||||
<button class="btn-entity-reject" data-entity-id="{eid}" title="Reject entity (N)">❌ Reject</button>
|
||||
<a class="btn-link" href="/wiki/entities/{eid}">View in wiki →</a>
|
||||
</div>
|
||||
<div class="cand-status" id="estatus-{eid}"></div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
_ENTITY_TRIAGE_SCRIPT = """
|
||||
<script>
|
||||
async function entityPromote(id) {
|
||||
const st = document.getElementById('estatus-' + id);
|
||||
st.textContent = 'Promoting…';
|
||||
st.className = 'cand-status ok';
|
||||
const r = await fetch('/entities/' + encodeURIComponent(id) + '/promote', {method:'POST'});
|
||||
if (r.ok) {
|
||||
st.textContent = '✅ Entity promoted';
|
||||
setTimeout(() => {
|
||||
const card = document.getElementById('ecand-' + id);
|
||||
if (card) { card.style.opacity = '0'; setTimeout(() => card.remove(), 300); }
|
||||
}, 400);
|
||||
} else st.textContent = '❌ ' + r.status;
|
||||
}
|
||||
async function entityReject(id) {
|
||||
const st = document.getElementById('estatus-' + id);
|
||||
st.textContent = 'Rejecting…';
|
||||
st.className = 'cand-status ok';
|
||||
const r = await fetch('/entities/' + encodeURIComponent(id) + '/reject', {method:'POST'});
|
||||
if (r.ok) {
|
||||
st.textContent = '❌ Entity rejected';
|
||||
setTimeout(() => {
|
||||
const card = document.getElementById('ecand-' + id);
|
||||
if (card) { card.style.opacity = '0'; setTimeout(() => card.remove(), 300); }
|
||||
}, 400);
|
||||
} else st.textContent = '❌ ' + r.status;
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
const eid = e.target.dataset?.entityId;
|
||||
if (!eid) return;
|
||||
if (e.target.classList.contains('btn-entity-promote')) entityPromote(eid);
|
||||
else if (e.target.classList.contains('btn-entity-reject')) entityReject(eid);
|
||||
});
|
||||
</script>
|
||||
"""
|
||||
|
||||
_ENTITY_TRIAGE_CSS = """
|
||||
<style>
|
||||
.cand-entity { border-left: 3px solid #059669; }
|
||||
.entity-type { background: #059669; color: white; padding: 0.1rem 0.5rem; border-radius: 3px; font-size: 0.75rem; }
|
||||
.entity-name { font-size: 1.15rem; font-weight: 600; margin-bottom: 0.3rem; }
|
||||
.entity-desc { opacity: 0.85; font-size: 0.95rem; }
|
||||
.btn-entity-promote { background: #059669; color: white; border-color: #059669; }
|
||||
.btn-entity-reject:hover { background: #dc2626; color: white; border-color: #dc2626; }
|
||||
.btn-link { padding: 0.4rem 0.9rem; text-decoration: none; color: var(--accent); border: 1px solid var(--border); border-radius: 4px; font-size: 0.88rem; }
|
||||
.btn-link:hover { background: var(--hover); }
|
||||
.section-break { border-top: 2px solid var(--border); margin: 2rem 0 1rem 0; padding-top: 1rem; }
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Phase 7A — Merge candidates (semantic dedup)
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
_MERGE_TRIAGE_CSS = """
|
||||
<style>
|
||||
.cand-merge { border-left: 3px solid #8b5cf6; }
|
||||
.merge-type { background: #8b5cf6; color: white; padding: 0.1rem 0.5rem; border-radius: 3px; font-size: 0.75rem; }
|
||||
.merge-sources { margin: 0.5rem 0 0.8rem 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.merge-source { background: var(--bg); border: 1px dashed var(--border); border-radius: 4px; padding: 0.4rem 0.6rem; font-size: 0.85rem; }
|
||||
.merge-source-meta { font-family: monospace; font-size: 0.72rem; opacity: 0.7; margin-bottom: 0.2rem; }
|
||||
.merge-arrow { text-align: center; font-size: 1.1rem; opacity: 0.5; margin: 0.3rem 0; }
|
||||
.merge-proposed { background: var(--card); border: 1px solid #8b5cf6; border-radius: 4px; padding: 0.5rem; }
|
||||
.btn-merge-approve { background: #8b5cf6; color: white; border-color: #8b5cf6; }
|
||||
.btn-merge-approve:hover { background: #7c3aed; }
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
def _render_merge_card(cand: dict) -> str:
|
||||
import json as _json
|
||||
cid = _escape(cand.get("id", ""))
|
||||
sim = cand.get("similarity") or 0.0
|
||||
sources = cand.get("sources") or []
|
||||
proposed_content = cand.get("proposed_content") or ""
|
||||
proposed_tags = cand.get("proposed_tags") or []
|
||||
proposed_project = cand.get("proposed_project") or ""
|
||||
reason = cand.get("reason") or ""
|
||||
|
||||
src_html = "".join(
|
||||
f"""
|
||||
<div class="merge-source">
|
||||
<div class="merge-source-meta">
|
||||
{_escape(s.get('id','')[:8])} · [{_escape(s.get('memory_type',''))}]
|
||||
· {_escape(s.get('project','') or '(global)')}
|
||||
· conf {float(s.get('confidence',0)):.2f}
|
||||
· refs {int(s.get('reference_count',0))}
|
||||
</div>
|
||||
<div>{_escape((s.get('content') or '')[:300])}</div>
|
||||
</div>
|
||||
"""
|
||||
for s in sources
|
||||
)
|
||||
tags_str = ", ".join(proposed_tags)
|
||||
return f"""
|
||||
<div class="cand cand-merge" id="mcand-{cid}" data-merge-id="{cid}">
|
||||
<div class="cand-head">
|
||||
<span class="cand-type merge-type">[merge · {len(sources)} sources]</span>
|
||||
<span class="cand-project">{_escape(proposed_project or '(global)')}</span>
|
||||
<span class="cand-meta">sim ≥ {sim:.2f}</span>
|
||||
</div>
|
||||
<div class="merge-sources">{src_html}</div>
|
||||
<div class="merge-arrow">↓ merged into ↓</div>
|
||||
<div class="merge-proposed">
|
||||
<textarea class="cand-content" id="mcontent-{cid}">{_escape(proposed_content)}</textarea>
|
||||
<div class="cand-meta-row">
|
||||
<label class="cand-field-label">Tags:
|
||||
<input type="text" class="cand-tags-input" id="mtags-{cid}" value="{_escape(tags_str)}" placeholder="tag1, tag2">
|
||||
</label>
|
||||
</div>
|
||||
{f'<div class="auto-triage-msg" style="margin-top:0.4rem;">💡 {_escape(reason)}</div>' if reason else ''}
|
||||
</div>
|
||||
<div class="cand-actions">
|
||||
<button class="btn-merge-approve" data-merge-id="{cid}" title="Approve merge">✅ Approve Merge</button>
|
||||
<button class="btn-reject" data-merge-id="{cid}" data-merge-reject="1" title="Keep separate">❌ Keep Separate</button>
|
||||
</div>
|
||||
<div class="cand-status" id="mstatus-{cid}"></div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
_MERGE_TRIAGE_SCRIPT = """
|
||||
<script>
|
||||
async function mergeApprove(id) {
|
||||
const st = document.getElementById('mstatus-' + id);
|
||||
st.textContent = 'Merging…';
|
||||
st.className = 'cand-status ok';
|
||||
const content = document.getElementById('mcontent-' + id).value;
|
||||
const tagsRaw = document.getElementById('mtags-' + id).value;
|
||||
const tags = tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
|
||||
const r = await fetch('/admin/memory/merge-candidates/' + encodeURIComponent(id) + '/approve', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({actor: 'human-triage', content: content, domain_tags: tags}),
|
||||
});
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
st.textContent = '✅ Merged → ' + (data.result_memory_id || '').slice(0, 8);
|
||||
setTimeout(() => {
|
||||
const card = document.getElementById('mcand-' + id);
|
||||
if (card) { card.style.opacity = '0'; setTimeout(() => card.remove(), 300); }
|
||||
}, 600);
|
||||
} else {
|
||||
const err = await r.text();
|
||||
st.textContent = '❌ ' + r.status + ': ' + err.slice(0, 120);
|
||||
st.className = 'cand-status err';
|
||||
}
|
||||
}
|
||||
|
||||
async function mergeReject(id) {
|
||||
const st = document.getElementById('mstatus-' + id);
|
||||
st.textContent = 'Rejecting…';
|
||||
st.className = 'cand-status ok';
|
||||
const r = await fetch('/admin/memory/merge-candidates/' + encodeURIComponent(id) + '/reject', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({actor: 'human-triage'}),
|
||||
});
|
||||
if (r.ok) {
|
||||
st.textContent = '❌ Kept separate';
|
||||
setTimeout(() => {
|
||||
const card = document.getElementById('mcand-' + id);
|
||||
if (card) { card.style.opacity = '0'; setTimeout(() => card.remove(), 300); }
|
||||
}, 400);
|
||||
} else st.textContent = '❌ ' + r.status;
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const mid = e.target.dataset?.mergeId;
|
||||
if (!mid) return;
|
||||
if (e.target.classList.contains('btn-merge-approve')) mergeApprove(mid);
|
||||
else if (e.target.dataset?.mergeReject) mergeReject(mid);
|
||||
});
|
||||
|
||||
async function requestDedupScan() {
|
||||
const btn = document.getElementById('dedup-btn');
|
||||
const status = document.getElementById('dedup-status');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Queuing…';
|
||||
status.textContent = '';
|
||||
status.className = 'auto-triage-msg';
|
||||
const threshold = parseFloat(document.getElementById('dedup-threshold').value || '0.88');
|
||||
const r = await fetch('/admin/memory/dedup-scan', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({project: '', similarity_threshold: threshold, max_batch: 50}),
|
||||
});
|
||||
if (r.ok) {
|
||||
status.textContent = `✓ Queued dedup scan at threshold ${threshold}. Host watcher runs every 2 min; refresh in ~3 min to see merge candidates.`;
|
||||
status.className = 'auto-triage-msg ok';
|
||||
} else {
|
||||
status.textContent = '✗ ' + r.status;
|
||||
status.className = 'auto-triage-msg err';
|
||||
}
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔗 Scan for duplicates';
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
def _render_dedup_bar() -> str:
|
||||
return """
|
||||
<div class="auto-triage-bar">
|
||||
<button id="dedup-btn" onclick="requestDedupScan()" title="Run semantic dedup scan on Dalidou host">
|
||||
🔗 Scan for duplicates
|
||||
</button>
|
||||
<label class="cand-field-label" style="margin:0 0.5rem;">
|
||||
Threshold:
|
||||
<input id="dedup-threshold" type="number" min="0.70" max="0.99" step="0.01" value="0.88"
|
||||
style="width:70px; padding:0.25rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:3px;">
|
||||
</label>
|
||||
<span id="dedup-status" class="auto-triage-msg">
|
||||
Finds semantically near-duplicate active memories and proposes LLM-drafted merges for review. Source memories become <code>superseded</code> on approve; nothing is deleted.
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _render_graduation_bar() -> str:
|
||||
"""The 'Graduate memories → entity candidates' control bar."""
|
||||
from atocore.projects.registry import load_project_registry
|
||||
try:
|
||||
projects = load_project_registry()
|
||||
options = '<option value="">(all projects)</option>' + "".join(
|
||||
f'<option value="{_escape(p.project_id)}">{_escape(p.project_id)}</option>'
|
||||
for p in projects
|
||||
)
|
||||
except Exception:
|
||||
options = '<option value="">(all projects)</option>'
|
||||
|
||||
return f"""
|
||||
<div class="auto-triage-bar graduation-bar">
|
||||
<button id="grad-btn" onclick="requestGraduation()" title="Run memory→entity graduation on Dalidou host">
|
||||
🎓 Graduate memories
|
||||
</button>
|
||||
<label class="cand-field-label">Project:
|
||||
<select id="grad-project" class="cand-type-select">{options}</select>
|
||||
</label>
|
||||
<label class="cand-field-label">Limit:
|
||||
<input id="grad-limit" type="number" class="cand-tags-input" style="max-width:80px"
|
||||
value="30" min="1" max="200" />
|
||||
</label>
|
||||
<span id="grad-status" class="auto-triage-msg">
|
||||
Scans active memories, asks the LLM "does this describe a typed entity?",
|
||||
and creates entity candidates. Review them in the Entity section below.
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
_GRADUATION_SCRIPT = """
|
||||
<script>
|
||||
async function requestGraduation() {
|
||||
const btn = document.getElementById('grad-btn');
|
||||
const status = document.getElementById('grad-status');
|
||||
const project = document.getElementById('grad-project').value;
|
||||
const limit = parseInt(document.getElementById('grad-limit').value || '30', 10);
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Requesting...';
|
||||
const r = await fetch('/admin/graduation/request', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({project, limit}),
|
||||
});
|
||||
if (r.ok) {
|
||||
const scope = project || 'all projects';
|
||||
status.textContent = `✓ Queued graduation for ${scope} (limit ${limit}). Host watcher runs every 2 min; refresh this page in ~3 min to see candidates.`;
|
||||
status.className = 'auto-triage-msg ok';
|
||||
btn.textContent = '✓ Requested';
|
||||
pollGraduationStatus();
|
||||
} else {
|
||||
status.textContent = '❌ Request failed: ' + r.status;
|
||||
status.className = 'auto-triage-msg err';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🎓 Graduate memories';
|
||||
}
|
||||
}
|
||||
|
||||
async function pollGraduationStatus() {
|
||||
const status = document.getElementById('grad-status');
|
||||
const btn = document.getElementById('grad-btn');
|
||||
let polls = 0;
|
||||
const timer = setInterval(async () => {
|
||||
polls++;
|
||||
const r = await fetch('/admin/graduation/status');
|
||||
if (!r.ok) return;
|
||||
const s = await r.json();
|
||||
if (s.is_running) {
|
||||
status.textContent = '⚙️ Graduation running... (started ' + (s.last_started_at || '?') + ')';
|
||||
status.className = 'auto-triage-msg ok';
|
||||
} else if (s.last_finished_at && !s.requested) {
|
||||
status.textContent = '✅ Finished: ' + s.last_finished_at + ' → ' + (s.last_result || 'complete');
|
||||
status.className = 'auto-triage-msg ok';
|
||||
if (btn) { btn.disabled = false; btn.textContent = '🎓 Graduate memories'; }
|
||||
clearInterval(timer);
|
||||
setTimeout(() => window.location.reload(), 3000);
|
||||
}
|
||||
if (polls > 120) { clearInterval(timer); } // ~20 min cap
|
||||
}, 10000);
|
||||
}
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
def render_triage_page(limit: int = 100) -> str:
|
||||
"""Render the full triage page with pending memory + entity candidates."""
|
||||
from atocore.engineering.service import get_entities
|
||||
|
||||
try:
|
||||
mem_candidates = get_memories(status="candidate", limit=limit)
|
||||
except Exception as e:
|
||||
body = f"<p>Error loading memory candidates: {_escape(str(e))}</p>"
|
||||
return render_html("Triage — AtoCore", body, breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")])
|
||||
|
||||
try:
|
||||
entity_candidates = get_entities(status="candidate", limit=limit)
|
||||
except Exception as e:
|
||||
entity_candidates = []
|
||||
|
||||
try:
|
||||
from atocore.memory.service import get_merge_candidates
|
||||
merge_candidates = get_merge_candidates(status="pending", limit=limit)
|
||||
except Exception:
|
||||
merge_candidates = []
|
||||
|
||||
total = len(mem_candidates) + len(entity_candidates) + len(merge_candidates)
|
||||
graduation_bar = _render_graduation_bar()
|
||||
dedup_bar = _render_dedup_bar()
|
||||
|
||||
if total == 0:
|
||||
body = _TRIAGE_CSS + _ENTITY_TRIAGE_CSS + _MERGE_TRIAGE_CSS + f"""
|
||||
<div class="triage-header">
|
||||
<h1>Triage Queue</h1>
|
||||
</div>
|
||||
{graduation_bar}
|
||||
{dedup_bar}
|
||||
<div class="empty">
|
||||
<p>🎉 No candidates to review.</p>
|
||||
<p>The auto-triage pipeline keeps this queue empty unless something needs your judgment.</p>
|
||||
<p>Use 🎓 Graduate memories to propose entity candidates, or 🔗 Scan for duplicates to find near-duplicate memories to merge.</p>
|
||||
</div>
|
||||
""" + _GRADUATION_SCRIPT + _MERGE_TRIAGE_SCRIPT
|
||||
return render_html("Triage — AtoCore", body, breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")])
|
||||
|
||||
# Memory cards
|
||||
mem_cards = "".join(_render_candidate_card(c) for c in mem_candidates)
|
||||
|
||||
# Merge cards (Phase 7A)
|
||||
merge_cards_html = ""
|
||||
if merge_candidates:
|
||||
merge_cards = "".join(_render_merge_card(c) for c in merge_candidates)
|
||||
merge_cards_html = f"""
|
||||
<div class="section-break">
|
||||
<h2>🔗 Merge Candidates ({len(merge_candidates)})</h2>
|
||||
<p class="auto-triage-msg">
|
||||
Semantically near-duplicate active memories. Approving merges the sources
|
||||
into the proposed unified memory; sources become <code>superseded</code>
|
||||
(not deleted — still queryable). You can edit the draft content and tags
|
||||
before approving.
|
||||
</p>
|
||||
</div>
|
||||
{merge_cards}
|
||||
"""
|
||||
|
||||
# Entity cards
|
||||
ent_cards_html = ""
|
||||
if entity_candidates:
|
||||
ent_cards = "".join(_render_entity_card(e) for e in entity_candidates)
|
||||
ent_cards_html = f"""
|
||||
<div class="section-break">
|
||||
<h2>🔧 Entity Candidates ({len(entity_candidates)})</h2>
|
||||
<p class="auto-triage-msg">
|
||||
Typed graph entries awaiting review. Promoting an entity connects it to
|
||||
the engineering knowledge graph (subsystems, requirements, decisions, etc.).
|
||||
</p>
|
||||
</div>
|
||||
{ent_cards}
|
||||
"""
|
||||
|
||||
body = _TRIAGE_CSS + _ENTITY_TRIAGE_CSS + _MERGE_TRIAGE_CSS + f"""
|
||||
<div class="triage-header">
|
||||
<h1>Triage Queue</h1>
|
||||
<span class="count">
|
||||
<span id="cand-count">{len(mem_candidates)}</span> memory ·
|
||||
{len(merge_candidates)} merge ·
|
||||
{len(entity_candidates)} entity
|
||||
</span>
|
||||
</div>
|
||||
<div class="triage-help">
|
||||
Review candidates the auto-triage wasn't sure about. Edit the content
|
||||
if needed, then promote or reject. Shortcuts: <kbd>Y</kbd> promote · <kbd>N</kbd>
|
||||
reject · <kbd>E</kbd> edit · <kbd>S</kbd> scroll to next.
|
||||
</div>
|
||||
<div class="auto-triage-bar">
|
||||
<button id="auto-triage-btn" onclick="requestAutoTriage()" title="Run auto_triage on Dalidou host">
|
||||
🤖 Auto-process queue
|
||||
</button>
|
||||
<span id="auto-triage-status" class="auto-triage-msg">
|
||||
Sends the full memory queue through 3-tier LLM triage on the host.
|
||||
Sonnet → Opus → auto-discard. Only genuinely ambiguous items land here.
|
||||
</span>
|
||||
</div>
|
||||
{graduation_bar}
|
||||
{dedup_bar}
|
||||
<h2>📝 Memory Candidates ({len(mem_candidates)})</h2>
|
||||
{mem_cards}
|
||||
{merge_cards_html}
|
||||
{ent_cards_html}
|
||||
""" + _TRIAGE_SCRIPT + _ENTITY_TRIAGE_SCRIPT + _GRADUATION_SCRIPT + _MERGE_TRIAGE_SCRIPT
|
||||
|
||||
return render_html(
|
||||
"Triage — AtoCore",
|
||||
body,
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Triage", "")],
|
||||
)
|
||||
834
src/atocore/engineering/wiki.py
Normal file
834
src/atocore/engineering/wiki.py
Normal file
@@ -0,0 +1,834 @@
|
||||
"""AtoCore Wiki — navigable HTML pages from structured data.
|
||||
|
||||
A lightweight wiki served directly from the AtoCore API. Every page is
|
||||
generated on-demand from the database so it's always current. Source of
|
||||
truth is the database — the wiki is a derived view.
|
||||
|
||||
Routes:
|
||||
/wiki Homepage with project list + search
|
||||
/wiki/projects/{name} Full project overview
|
||||
/wiki/entities/{id} Entity detail with relationships
|
||||
/wiki/search?q=... Search entities, memories, state
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import markdown as md
|
||||
|
||||
from atocore.context.project_state import get_state
|
||||
from atocore.engineering.service import (
|
||||
get_entities,
|
||||
get_entity,
|
||||
get_entity_with_context,
|
||||
get_relationships,
|
||||
)
|
||||
from atocore.memory.service import get_memories
|
||||
from atocore.projects.registry import load_project_registry
|
||||
|
||||
|
||||
_TOP_NAV_LINKS = [
|
||||
("🏠 Home", "/wiki"),
|
||||
("📡 Activity", "/wiki/activity"),
|
||||
("🔀 Triage", "/admin/triage"),
|
||||
("📊 Dashboard", "/admin/dashboard"),
|
||||
]
|
||||
|
||||
|
||||
def _render_topnav(active_path: str = "") -> str:
|
||||
items = []
|
||||
for label, href in _TOP_NAV_LINKS:
|
||||
cls = "topnav-item active" if href == active_path else "topnav-item"
|
||||
items.append(f'<a href="{href}" class="{cls}">{label}</a>')
|
||||
return f'<nav class="topnav">{" ".join(items)}</nav>'
|
||||
|
||||
|
||||
def render_html(title: str, body_html: str, breadcrumbs: list[tuple[str, str]] | None = None, active_path: str = "") -> str:
|
||||
topnav = _render_topnav(active_path)
|
||||
crumbs = ""
|
||||
if breadcrumbs:
|
||||
parts = []
|
||||
for label, href in breadcrumbs:
|
||||
if href:
|
||||
parts.append(f'<a href="{href}">{label}</a>')
|
||||
else:
|
||||
parts.append(f"<span>{label}</span>")
|
||||
crumbs = f'<nav class="breadcrumbs">{" / ".join(parts)}</nav>'
|
||||
|
||||
nav = topnav + crumbs
|
||||
return _TEMPLATE.replace("{{title}}", title).replace("{{nav}}", nav).replace("{{body}}", body_html)
|
||||
|
||||
|
||||
def render_homepage() -> str:
|
||||
projects = []
|
||||
try:
|
||||
registered = load_project_registry()
|
||||
for p in registered:
|
||||
entity_count = len(get_entities(project=p.project_id, limit=200))
|
||||
memory_count = len(get_memories(project=p.project_id, active_only=True, limit=200))
|
||||
state_entries = get_state(p.project_id)
|
||||
|
||||
# Pull stage/type/client from state entries
|
||||
stage = ""
|
||||
proj_type = ""
|
||||
client = ""
|
||||
for e in state_entries:
|
||||
if e.category == "status":
|
||||
if e.key == "stage":
|
||||
stage = e.value
|
||||
elif e.key == "type":
|
||||
proj_type = e.value
|
||||
elif e.key == "client":
|
||||
client = e.value
|
||||
|
||||
projects.append({
|
||||
"id": p.project_id,
|
||||
"description": p.description,
|
||||
"entities": entity_count,
|
||||
"memories": memory_count,
|
||||
"state": len(state_entries),
|
||||
"stage": stage,
|
||||
"type": proj_type,
|
||||
"client": client,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Group by high-level bucket
|
||||
buckets: dict[str, list] = {
|
||||
"Active Contracts": [],
|
||||
"Leads & Prospects": [],
|
||||
"Internal Tools & Infra": [],
|
||||
"Other": [],
|
||||
}
|
||||
for p in projects:
|
||||
t = p["type"].lower()
|
||||
s = p["stage"].lower()
|
||||
if "lead" in t or "lead" in s or "prospect" in s:
|
||||
buckets["Leads & Prospects"].append(p)
|
||||
elif "contract" in t or ("active" in s and "contract" in s):
|
||||
buckets["Active Contracts"].append(p)
|
||||
elif "infra" in t or "tool" in t or "internal" in t:
|
||||
buckets["Internal Tools & Infra"].append(p)
|
||||
else:
|
||||
buckets["Other"].append(p)
|
||||
|
||||
lines = ['<h1>AtoCore Wiki</h1>']
|
||||
lines.append('<form class="search-box" action="/wiki/search" method="get">')
|
||||
lines.append('<input type="text" name="q" placeholder="Search entities, memories, projects..." autofocus>')
|
||||
lines.append('<button type="submit">Search</button>')
|
||||
lines.append('</form>')
|
||||
|
||||
# What's happening — autonomous activity snippet
|
||||
try:
|
||||
from atocore.memory.service import get_recent_audit
|
||||
recent = get_recent_audit(limit=30)
|
||||
by_action: dict[str, int] = {}
|
||||
by_actor: dict[str, int] = {}
|
||||
for a in recent:
|
||||
by_action[a["action"]] = by_action.get(a["action"], 0) + 1
|
||||
by_actor[a["actor"]] = by_actor.get(a["actor"], 0) + 1
|
||||
# Surface autonomous actors specifically
|
||||
auto_actors = {k: v for k, v in by_actor.items()
|
||||
if k.startswith("auto-") or k == "confidence-decay"
|
||||
or k == "phase10-auto-promote" or k == "transient-to-durable"}
|
||||
if recent:
|
||||
lines.append('<div class="activity-snippet">')
|
||||
lines.append('<h3>📡 What the brain is doing</h3>')
|
||||
top_actions = sorted(by_action.items(), key=lambda x: -x[1])[:6]
|
||||
lines.append('<div class="stat-row">' +
|
||||
"".join(f'<span>{a}: {n}</span>' for a, n in top_actions) +
|
||||
'</div>')
|
||||
if auto_actors:
|
||||
lines.append(f'<p style="font-size:0.9rem; margin:0.3rem 0;">Autonomous actors: ' +
|
||||
" · ".join(f'<code>{k}</code> ({v})' for k, v in auto_actors.items()) +
|
||||
'</p>')
|
||||
lines.append('<p style="font-size:0.85rem; margin:0;"><a href="/wiki/activity">Full timeline →</a></p>')
|
||||
lines.append('</div>')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for bucket_name, items in buckets.items():
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f'<h2>{bucket_name}</h2>')
|
||||
lines.append('<div class="card-grid">')
|
||||
for p in items:
|
||||
client_line = f'<div class="client">{p["client"]}</div>' if p["client"] else ''
|
||||
stage_tag = f'<span class="tag">{p["stage"].split(" — ")[0]}</span>' if p["stage"] else ''
|
||||
lines.append(f'<a href="/wiki/projects/{p["id"]}" class="card">')
|
||||
lines.append(f'<h3>{p["id"]} {stage_tag}</h3>')
|
||||
lines.append(client_line)
|
||||
lines.append(f'<p>{p["description"][:140]}</p>')
|
||||
lines.append(f'<div class="stats">{p["entities"]} entities · {p["memories"]} memories · {p["state"]} state</div>')
|
||||
lines.append('</a>')
|
||||
lines.append('</div>')
|
||||
|
||||
# Phase 6 C.2: Emerging projects section
|
||||
try:
|
||||
import json as _json
|
||||
emerging_projects = []
|
||||
state_entries = get_state("atocore")
|
||||
for e in state_entries:
|
||||
if e.category == "proposals" and e.key == "unregistered_projects":
|
||||
try:
|
||||
emerging_projects = _json.loads(e.value)
|
||||
except Exception:
|
||||
emerging_projects = []
|
||||
break
|
||||
if emerging_projects:
|
||||
lines.append('<h2>📋 Emerging</h2>')
|
||||
lines.append('<p class="emerging-intro">Projects that appear in memories but aren\'t yet registered. '
|
||||
'One click to promote them to first-class projects.</p>')
|
||||
lines.append('<div class="emerging-grid">')
|
||||
for ep in emerging_projects[:10]:
|
||||
name = ep.get("project", "?")
|
||||
count = ep.get("count", 0)
|
||||
samples = ep.get("sample_contents", [])
|
||||
samples_html = "".join(f'<li>{s[:120]}</li>' for s in samples[:2])
|
||||
lines.append(
|
||||
f'<div class="emerging-card">'
|
||||
f'<h3>{name}</h3>'
|
||||
f'<div class="emerging-count">{count} memories</div>'
|
||||
f'<ul class="emerging-samples">{samples_html}</ul>'
|
||||
f'<button class="btn-register-emerging" onclick="registerEmerging({name!r})">📌 Register as project</button>'
|
||||
f'</div>'
|
||||
)
|
||||
lines.append('</div>')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Quick stats
|
||||
all_entities = get_entities(limit=500)
|
||||
all_memories = get_memories(active_only=True, limit=500)
|
||||
pending = get_memories(status="candidate", limit=500)
|
||||
lines.append('<h2>System</h2>')
|
||||
lines.append(f'<p>{len(all_entities)} entities · {len(all_memories)} active memories · {len(projects)} projects</p>')
|
||||
|
||||
# Triage queue prompt — surfaced prominently if non-empty
|
||||
if pending:
|
||||
tone = "triage-warning" if len(pending) > 50 else "triage-notice"
|
||||
lines.append(
|
||||
f'<p class="{tone}">🗂️ <strong>{len(pending)} candidates</strong> awaiting triage — '
|
||||
f'<a href="/admin/triage">review now →</a></p>'
|
||||
)
|
||||
|
||||
lines.append(f'<p><a href="/admin/triage">Triage Queue</a> · <a href="/admin/dashboard">API Dashboard (JSON)</a> · <a href="/health">Health Check</a></p>')
|
||||
|
||||
return render_html("AtoCore Wiki", "\n".join(lines), active_path="/wiki")
|
||||
|
||||
|
||||
def render_project(project: str) -> str:
|
||||
from atocore.engineering.mirror import generate_project_overview
|
||||
|
||||
markdown_content = generate_project_overview(project)
|
||||
# Convert entity names to links
|
||||
entities = get_entities(project=project, limit=200)
|
||||
html_body = md.markdown(markdown_content, extensions=["tables", "fenced_code"])
|
||||
|
||||
for ent in sorted(entities, key=lambda e: len(e.name), reverse=True):
|
||||
linked = f'<a href="/wiki/entities/{ent.id}" title="{ent.entity_type}">{ent.name}</a>'
|
||||
html_body = html_body.replace(f"<strong>{ent.name}</strong>", f"<strong>{linked}</strong>", 1)
|
||||
|
||||
return render_html(
|
||||
f"{project}",
|
||||
html_body,
|
||||
breadcrumbs=[("Wiki", "/wiki"), (project, "")],
|
||||
)
|
||||
|
||||
|
||||
def render_entity(entity_id: str) -> str | None:
|
||||
ctx = get_entity_with_context(entity_id)
|
||||
if ctx is None:
|
||||
return None
|
||||
|
||||
ent = ctx["entity"]
|
||||
lines = [f'<h1>[{ent.entity_type}] {ent.name}</h1>']
|
||||
|
||||
if ent.project:
|
||||
lines.append(f'<p>Project: <a href="/wiki/projects/{ent.project}">{ent.project}</a></p>')
|
||||
if ent.description:
|
||||
lines.append(f'<p>{ent.description}</p>')
|
||||
if ent.properties:
|
||||
lines.append('<h2>Properties</h2><ul>')
|
||||
for k, v in ent.properties.items():
|
||||
lines.append(f'<li><strong>{k}</strong>: {v}</li>')
|
||||
lines.append('</ul>')
|
||||
|
||||
lines.append(f'<p class="meta">confidence: {ent.confidence} · status: {ent.status} · created: {ent.created_at}</p>')
|
||||
|
||||
if ctx["relationships"]:
|
||||
lines.append('<h2>Relationships</h2><ul>')
|
||||
for rel in ctx["relationships"]:
|
||||
other_id = rel.target_entity_id if rel.source_entity_id == entity_id else rel.source_entity_id
|
||||
other = ctx["related_entities"].get(other_id)
|
||||
if other:
|
||||
direction = "\u2192" if rel.source_entity_id == entity_id else "\u2190"
|
||||
lines.append(
|
||||
f'<li>{direction} <em>{rel.relationship_type}</em> '
|
||||
f'<a href="/wiki/entities/{other_id}">[{other.entity_type}] {other.name}</a></li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
|
||||
breadcrumbs = [("Wiki", "/wiki")]
|
||||
if ent.project:
|
||||
breadcrumbs.append((ent.project, f"/wiki/projects/{ent.project}"))
|
||||
breadcrumbs.append((ent.name, ""))
|
||||
|
||||
return render_html(ent.name, "\n".join(lines), breadcrumbs=breadcrumbs)
|
||||
|
||||
|
||||
def render_search(query: str) -> str:
|
||||
lines = [f'<h1>Search: "{query}"</h1>']
|
||||
|
||||
# Search entities by name
|
||||
entities = get_entities(name_contains=query, limit=20)
|
||||
if entities:
|
||||
lines.append(f'<h2>Entities ({len(entities)})</h2><ul>')
|
||||
for e in entities:
|
||||
proj = f' <span class="tag">{e.project}</span>' if e.project else ''
|
||||
lines.append(
|
||||
f'<li><a href="/wiki/entities/{e.id}">[{e.entity_type}] {e.name}</a>{proj}'
|
||||
f'{" — " + e.description[:100] if e.description else ""}</li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
|
||||
# Search memories — match on content OR domain_tags (Phase 3)
|
||||
all_memories = get_memories(active_only=True, limit=200)
|
||||
query_lower = query.lower()
|
||||
matching_mems = [
|
||||
m for m in all_memories
|
||||
if query_lower in m.content.lower()
|
||||
or any(query_lower in (t or "").lower() for t in (m.domain_tags or []))
|
||||
][:20]
|
||||
if matching_mems:
|
||||
lines.append(f'<h2>Memories ({len(matching_mems)})</h2><ul>')
|
||||
for m in matching_mems:
|
||||
proj = f' <span class="tag">{m.project}</span>' if m.project else ''
|
||||
tags_html = ""
|
||||
if m.domain_tags:
|
||||
tag_links = " ".join(
|
||||
f'<a href="/wiki/search?q={t}" class="tag-badge">{t}</a>'
|
||||
for t in m.domain_tags[:5]
|
||||
)
|
||||
tags_html = f' <span class="mem-tags">{tag_links}</span>'
|
||||
expiry_html = ""
|
||||
if m.valid_until:
|
||||
expiry_html = f' <span class="mem-expiry">valid until {m.valid_until[:10]}</span>'
|
||||
lines.append(
|
||||
f'<li>[{m.memory_type}]{proj}{tags_html}{expiry_html} '
|
||||
f'{m.content[:200]}</li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
|
||||
if not entities and not matching_mems:
|
||||
lines.append('<p>No results found.</p>')
|
||||
|
||||
lines.append('<form class="search-box" action="/wiki/search" method="get">')
|
||||
lines.append(f'<input type="text" name="q" value="{query}" autofocus>')
|
||||
lines.append('<button type="submit">Search</button>')
|
||||
lines.append('</form>')
|
||||
|
||||
return render_html(
|
||||
f"Search: {query}",
|
||||
"\n".join(lines),
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Search", "")],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# /wiki/capture — DEPRECATED emergency paste-in form.
|
||||
# Kept as an endpoint because POST /interactions is public anyway, but
|
||||
# REMOVED from the topnav so it's not promoted as the capture path.
|
||||
# The sanctioned surfaces are Claude Code (Stop + UserPromptSubmit
|
||||
# hooks) and OpenClaw (capture plugin with 7I context injection).
|
||||
# This form is explicitly a last-resort for when someone has to feed
|
||||
# in an external log and can't get the normal hooks to reach it.
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_capture() -> str:
|
||||
lines = ['<h1>📥 Manual capture (fallback only)</h1>']
|
||||
lines.append(
|
||||
'<div class="triage-warning"><strong>This is not the capture path.</strong> '
|
||||
'The sanctioned capture surfaces are Claude Code (Stop hook auto-captures every turn) '
|
||||
'and OpenClaw (plugin auto-captures + injects AtoCore context on every agent turn). '
|
||||
'This form exists only as a last resort for external logs you can\'t get into the normal pipeline.</div>'
|
||||
)
|
||||
lines.append(
|
||||
'<p>If you\'re reaching for this page because you had a chat somewhere AtoCore didn\'t see, '
|
||||
'fix the capture surface instead — don\'t paste. The deliberate scope is Claude Code + OpenClaw.</p>'
|
||||
)
|
||||
lines.append('<p class="meta">Your prompt + the assistant\'s response. Project is optional — '
|
||||
'the extractor infers it from content.</p>')
|
||||
lines.append("""
|
||||
<form id="capture-form" style="display:flex; flex-direction:column; gap:0.8rem; margin-top:1rem;">
|
||||
<label><strong>Your prompt / question</strong>
|
||||
<textarea id="cap-prompt" required rows="4"
|
||||
style="width:100%; padding:0.6rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; font-family:inherit; font-size:0.95rem;"
|
||||
placeholder="Paste what you asked…"></textarea>
|
||||
</label>
|
||||
<label><strong>Assistant response</strong>
|
||||
<textarea id="cap-response" required rows="10"
|
||||
style="width:100%; padding:0.6rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; font-family:inherit; font-size:0.95rem;"
|
||||
placeholder="Paste the full assistant response…"></textarea>
|
||||
</label>
|
||||
<div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<label style="display:flex; gap:0.35rem; align-items:center;">Project (optional):
|
||||
<input type="text" id="cap-project" placeholder="auto-detect"
|
||||
style="padding:0.35rem 0.6rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:4px; font-family:monospace; width:180px;">
|
||||
</label>
|
||||
<label style="display:flex; gap:0.35rem; align-items:center;">Source:
|
||||
<select id="cap-source" style="padding:0.35rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:4px;">
|
||||
<option value="claude-desktop">Claude Desktop</option>
|
||||
<option value="claude-web">Claude.ai web</option>
|
||||
<option value="claude-mobile">Claude mobile</option>
|
||||
<option value="chatgpt">ChatGPT</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit"
|
||||
style="padding:0.6rem 1.2rem; background:var(--accent); color:white; border:none; border-radius:6px; cursor:pointer; font-size:1rem; font-weight:600; align-self:flex-start;">
|
||||
Save to AtoCore
|
||||
</button>
|
||||
</form>
|
||||
<div id="cap-status" style="margin-top:1rem; font-size:0.9rem; min-height:1.5em;"></div>
|
||||
|
||||
<script>
|
||||
document.getElementById('capture-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const prompt = document.getElementById('cap-prompt').value.trim();
|
||||
const response = document.getElementById('cap-response').value.trim();
|
||||
const project = document.getElementById('cap-project').value.trim();
|
||||
const source = document.getElementById('cap-source').value;
|
||||
const status = document.getElementById('cap-status');
|
||||
if (!prompt || !response) { status.textContent = 'Need both prompt and response.'; return; }
|
||||
status.textContent = 'Saving…';
|
||||
try {
|
||||
const r = await fetch('/interactions', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
prompt: prompt, response: response,
|
||||
client: source, project: project, reinforce: true
|
||||
})
|
||||
});
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
status.innerHTML = '✅ Saved — interaction ' + (data.interaction_id || '?').slice(0,8) +
|
||||
'. Runs through extraction + triage within the hour.<br>' +
|
||||
'<a href="/interactions/' + (data.interaction_id || '') + '">view</a>';
|
||||
document.getElementById('capture-form').reset();
|
||||
} else {
|
||||
status.textContent = '❌ ' + r.status + ': ' + (await r.text()).slice(0, 200);
|
||||
}
|
||||
} catch (err) { status.textContent = '❌ ' + err.message; }
|
||||
});
|
||||
</script>
|
||||
""")
|
||||
lines.append(
|
||||
'<h2>How this works</h2>'
|
||||
'<ul>'
|
||||
'<li><strong>Claude Code</strong> → auto-captured via Stop hook</li>'
|
||||
'<li><strong>OpenClaw</strong> → auto-captured + gets AtoCore context injected on prompt start (Phase 7I)</li>'
|
||||
'<li><strong>Anything else</strong> (Claude Desktop, mobile, web, ChatGPT) → paste here</li>'
|
||||
'</ul>'
|
||||
'<p>The extractor is aggressive about capturing signal — don\'t hand-filter. '
|
||||
'If the conversation had nothing durable, triage will auto-reject.</p>'
|
||||
)
|
||||
|
||||
return render_html(
|
||||
"Capture — AtoCore",
|
||||
"\n".join(lines),
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Capture", "")],
|
||||
active_path="/wiki/capture",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Phase 7E — /wiki/memories/{id}: memory detail page
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_memory_detail(memory_id: str) -> str | None:
|
||||
"""Full view of a single memory: content, audit trail, source refs,
|
||||
neighbors, graduation status. Fills the drill-down gap the list
|
||||
views can't."""
|
||||
from atocore.memory.service import get_memory_audit
|
||||
from atocore.models.database import get_connection
|
||||
|
||||
with get_connection() as conn:
|
||||
row = conn.execute("SELECT * FROM memories WHERE id = ?", (memory_id,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
import json as _json
|
||||
mem = dict(row)
|
||||
try:
|
||||
tags = _json.loads(mem.get("domain_tags") or "[]") or []
|
||||
except Exception:
|
||||
tags = []
|
||||
|
||||
lines = [f'<h1>{mem["memory_type"]}: <span style="color:var(--text);">{mem["content"][:80]}</span></h1>']
|
||||
if len(mem["content"]) > 80:
|
||||
lines.append(f'<blockquote><p>{mem["content"]}</p></blockquote>')
|
||||
|
||||
# Metadata row
|
||||
meta_items = [
|
||||
f'<span class="tag">{mem["status"]}</span>',
|
||||
f'<strong>{mem["memory_type"]}</strong>',
|
||||
]
|
||||
if mem.get("project"):
|
||||
meta_items.append(f'<a href="/wiki/projects/{mem["project"]}">{mem["project"]}</a>')
|
||||
meta_items.append(f'confidence: <strong>{float(mem.get("confidence") or 0):.2f}</strong>')
|
||||
meta_items.append(f'refs: <strong>{int(mem.get("reference_count") or 0)}</strong>')
|
||||
if mem.get("valid_until"):
|
||||
meta_items.append(f'<span class="mem-expiry">valid until {str(mem["valid_until"])[:10]}</span>')
|
||||
lines.append(f'<p>{" · ".join(meta_items)}</p>')
|
||||
|
||||
if tags:
|
||||
tag_links = " ".join(f'<a href="/wiki/domains/{t}" class="tag-badge">{t}</a>' for t in tags)
|
||||
lines.append(f'<p><span class="mem-tags">{tag_links}</span></p>')
|
||||
|
||||
lines.append(f'<p class="meta">id: <code>{mem["id"]}</code> · created: {mem["created_at"]}'
|
||||
f' · updated: {mem.get("updated_at", "?")}'
|
||||
+ (f' · last referenced: {mem["last_referenced_at"]}' if mem.get("last_referenced_at") else '')
|
||||
+ '</p>')
|
||||
|
||||
# Graduation
|
||||
if mem.get("graduated_to_entity_id"):
|
||||
eid = mem["graduated_to_entity_id"]
|
||||
lines.append(
|
||||
f'<h2>🎓 Graduated</h2>'
|
||||
f'<p>This memory was promoted to a typed entity: '
|
||||
f'<a href="/wiki/entities/{eid}">{eid[:8]}</a></p>'
|
||||
)
|
||||
|
||||
# Source chunk
|
||||
if mem.get("source_chunk_id"):
|
||||
lines.append(f'<h2>Source chunk</h2><p><code>{mem["source_chunk_id"]}</code></p>')
|
||||
|
||||
# Audit trail
|
||||
audit = get_memory_audit(memory_id, limit=50)
|
||||
if audit:
|
||||
lines.append(f'<h2>Audit trail ({len(audit)} events)</h2><ul>')
|
||||
for a in audit:
|
||||
note = f' — {a["note"]}' if a.get("note") else ""
|
||||
lines.append(
|
||||
f'<li><code>{a["timestamp"]}</code> '
|
||||
f'<strong>{a["action"]}</strong> '
|
||||
f'<em>{a["actor"]}</em>{note}</li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
|
||||
# Neighbors by shared tag
|
||||
if tags:
|
||||
from atocore.memory.service import get_memories as _get_memories
|
||||
neighbors = []
|
||||
for t in tags[:3]:
|
||||
for other in _get_memories(active_only=True, limit=30):
|
||||
if other.id == memory_id:
|
||||
continue
|
||||
if any(ot == t for ot in (other.domain_tags or [])):
|
||||
neighbors.append(other)
|
||||
# Dedupe
|
||||
seen = set()
|
||||
uniq = []
|
||||
for n in neighbors:
|
||||
if n.id in seen:
|
||||
continue
|
||||
seen.add(n.id)
|
||||
uniq.append(n)
|
||||
if uniq:
|
||||
lines.append(f'<h2>Related (by tag)</h2><ul>')
|
||||
for n in uniq[:10]:
|
||||
lines.append(
|
||||
f'<li><a href="/wiki/memories/{n.id}">[{n.memory_type}] '
|
||||
f'{n.content[:120]}</a>'
|
||||
+ (f' <span class="tag">{n.project}</span>' if n.project else '')
|
||||
+ '</li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
|
||||
return render_html(
|
||||
f"Memory {memory_id[:8]}",
|
||||
"\n".join(lines),
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Memory", "")],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Phase 7F — /wiki/domains/{tag}: cross-project domain view
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_domain(tag: str) -> str:
|
||||
"""All memories + entities carrying a given domain_tag, grouped by project.
|
||||
Answers 'what does the brain know about optics, across all projects?'"""
|
||||
tag = (tag or "").strip().lower()
|
||||
if not tag:
|
||||
return render_html("Domain", "<p>No tag specified.</p>",
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Domains", "")])
|
||||
|
||||
all_mems = get_memories(active_only=True, limit=500)
|
||||
matching = [m for m in all_mems
|
||||
if any((t or "").lower() == tag for t in (m.domain_tags or []))]
|
||||
|
||||
# Group by project
|
||||
by_project: dict[str, list] = {}
|
||||
for m in matching:
|
||||
by_project.setdefault(m.project or "(global)", []).append(m)
|
||||
|
||||
lines = [f'<h1>Domain: <code>{tag}</code></h1>']
|
||||
lines.append(f'<p class="meta">{len(matching)} active memories across {len(by_project)} projects</p>')
|
||||
|
||||
if not matching:
|
||||
lines.append(
|
||||
f'<p>No memories currently carry the tag <code>{tag}</code>.</p>'
|
||||
'<p>Domain tags are assigned by the extractor when it identifies '
|
||||
'the topical scope of a memory. They update over time.</p>'
|
||||
)
|
||||
return render_html(
|
||||
f"Domain: {tag}",
|
||||
"\n".join(lines),
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Domains", ""), (tag, "")],
|
||||
)
|
||||
|
||||
# Sort projects by count descending, (global) last
|
||||
def sort_key(item: tuple[str, list]) -> tuple[int, int]:
|
||||
proj, mems = item
|
||||
return (1 if proj == "(global)" else 0, -len(mems))
|
||||
|
||||
for proj, mems in sorted(by_project.items(), key=sort_key):
|
||||
proj_link = proj if proj == "(global)" else f'<a href="/wiki/projects/{proj}">{proj}</a>'
|
||||
lines.append(f'<h2>{proj_link} ({len(mems)})</h2><ul>')
|
||||
for m in mems:
|
||||
other_tags = [t for t in (m.domain_tags or []) if t != tag][:3]
|
||||
other_tags_html = ""
|
||||
if other_tags:
|
||||
other_tags_html = ' <span class="mem-tags">' + " ".join(
|
||||
f'<a href="/wiki/domains/{t}" class="tag-badge">{t}</a>' for t in other_tags
|
||||
) + '</span>'
|
||||
lines.append(
|
||||
f'<li><a href="/wiki/memories/{m.id}">[{m.memory_type}] '
|
||||
f'{m.content[:200]}</a>'
|
||||
f' <span class="meta">conf {m.confidence:.2f} · refs {m.reference_count}</span>'
|
||||
f'{other_tags_html}</li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
|
||||
# Entities with this tag (if any have tags — currently they might not)
|
||||
try:
|
||||
all_entities = get_entities(limit=500)
|
||||
ent_matching = []
|
||||
for e in all_entities:
|
||||
tags = e.properties.get("domain_tags") if e.properties else []
|
||||
if isinstance(tags, list) and tag in [str(t).lower() for t in tags]:
|
||||
ent_matching.append(e)
|
||||
if ent_matching:
|
||||
lines.append(f'<h2>🔧 Entities ({len(ent_matching)})</h2><ul>')
|
||||
for e in ent_matching:
|
||||
lines.append(
|
||||
f'<li><a href="/wiki/entities/{e.id}">[{e.entity_type}] {e.name}</a>'
|
||||
+ (f' <span class="tag">{e.project}</span>' if e.project else '')
|
||||
+ '</li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return render_html(
|
||||
f"Domain: {tag}",
|
||||
"\n".join(lines),
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Domains", ""), (tag, "")],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# /wiki/activity — autonomous-activity feed
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_activity(hours: int = 48, limit: int = 100) -> str:
|
||||
"""Timeline of what the autonomous pipeline did recently. Answers
|
||||
'what has the brain been doing while I was away?'"""
|
||||
from atocore.memory.service import get_recent_audit
|
||||
|
||||
audit = get_recent_audit(limit=limit)
|
||||
|
||||
# Group events by category for summary
|
||||
by_action: dict[str, int] = {}
|
||||
by_actor: dict[str, int] = {}
|
||||
for a in audit:
|
||||
by_action[a["action"]] = by_action.get(a["action"], 0) + 1
|
||||
by_actor[a["actor"]] = by_actor.get(a["actor"], 0) + 1
|
||||
|
||||
lines = [f'<h1>📡 Activity Feed</h1>']
|
||||
lines.append(f'<p class="meta">Last {len(audit)} events in the memory audit log</p>')
|
||||
|
||||
# Summary chips
|
||||
if by_action or by_actor:
|
||||
lines.append('<h2>Summary</h2>')
|
||||
lines.append('<p><strong>By action:</strong> ' +
|
||||
" · ".join(f'{k}: {v}' for k, v in sorted(by_action.items(), key=lambda x: -x[1])) +
|
||||
'</p>')
|
||||
lines.append('<p><strong>By actor:</strong> ' +
|
||||
" · ".join(f'<code>{k}</code>: {v}' for k, v in sorted(by_actor.items(), key=lambda x: -x[1])) +
|
||||
'</p>')
|
||||
|
||||
# Action-type color/emoji
|
||||
action_emoji = {
|
||||
"created": "➕", "promoted": "✅", "rejected": "❌", "invalidated": "🚫",
|
||||
"superseded": "🔀", "reinforced": "🔁", "updated": "✏️",
|
||||
"auto_promoted": "⚡", "created_via_merge": "🔗",
|
||||
"valid_until_extended": "⏳", "tag_canonicalized": "🏷️",
|
||||
}
|
||||
|
||||
lines.append('<h2>Timeline</h2><ul>')
|
||||
for a in audit:
|
||||
emoji = action_emoji.get(a["action"], "•")
|
||||
preview = a.get("content_preview") or ""
|
||||
ts_short = a["timestamp"][:16] if a.get("timestamp") else "?"
|
||||
mid_short = (a.get("memory_id") or "")[:8]
|
||||
note = f' — <em>{a["note"]}</em>' if a.get("note") else ""
|
||||
lines.append(
|
||||
f'<li>{emoji} <code>{ts_short}</code> '
|
||||
f'<strong>{a["action"]}</strong> '
|
||||
f'<em>{a["actor"]}</em> '
|
||||
f'<a href="/wiki/memories/{a["memory_id"]}">{mid_short}</a>'
|
||||
f'{note}'
|
||||
+ (f'<br><span style="opacity:0.6; font-size:0.85rem; margin-left:1.5rem;">{preview[:140]}</span>' if preview else '')
|
||||
+ '</li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
|
||||
return render_html(
|
||||
"Activity — AtoCore",
|
||||
"\n".join(lines),
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Activity", "")],
|
||||
active_path="/wiki/activity",
|
||||
)
|
||||
|
||||
|
||||
_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{title}} — AtoCore</title>
|
||||
<style>
|
||||
:root { --bg: #fafafa; --text: #1a1a2e; --accent: #2563eb; --border: #e2e8f0; --card: #fff; --hover: #f1f5f9; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg: #0f172a; --text: #e2e8f0; --accent: #60a5fa; --border: #334155; --card: #1e293b; --hover: #334155; }
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.7; color: var(--text); background: var(--bg);
|
||||
max-width: 800px; margin: 0 auto; padding: 1.5rem;
|
||||
}
|
||||
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; color: var(--accent); }
|
||||
h2 { font-size: 1.3rem; margin-top: 2rem; margin-bottom: 0.6rem; padding-bottom: 0.2rem; border-bottom: 2px solid var(--border); }
|
||||
h3 { font-size: 1.1rem; margin-top: 1.2rem; margin-bottom: 0.4rem; }
|
||||
p { margin-bottom: 0.8rem; }
|
||||
ul { margin-left: 1.5rem; margin-bottom: 1rem; }
|
||||
li { margin-bottom: 0.3rem; }
|
||||
li ul { margin-top: 0.2rem; }
|
||||
strong { color: var(--accent); font-weight: 600; }
|
||||
em { opacity: 0.7; font-size: 0.9em; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
blockquote {
|
||||
background: var(--card); border-left: 4px solid var(--accent);
|
||||
padding: 0.6rem 1rem; margin: 1rem 0; border-radius: 0 6px 6px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
|
||||
.breadcrumbs { margin-bottom: 1.5rem; font-size: 0.85em; opacity: 0.7; }
|
||||
.breadcrumbs a { opacity: 0.8; }
|
||||
.topnav { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-bottom: 1rem; padding-bottom: 0.8rem; border-bottom: 1px solid var(--border); }
|
||||
.topnav-item { padding: 0.35rem 0.8rem; background: var(--card); border: 1px solid var(--border); border-radius: 6px; font-size: 0.88rem; color: var(--text); opacity: 0.75; text-decoration: none; }
|
||||
.topnav-item:hover { opacity: 1; background: var(--hover); text-decoration: none; }
|
||||
.topnav-item.active { background: var(--accent); color: white; border-color: var(--accent); opacity: 1; }
|
||||
.topnav-item.active:hover { background: var(--accent); }
|
||||
.activity-snippet { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin: 1rem 0; }
|
||||
.activity-snippet h3 { color: var(--accent); margin-bottom: 0.4rem; }
|
||||
.activity-snippet ul { margin: 0.3rem 0 0 1.2rem; font-size: 0.9rem; }
|
||||
.activity-snippet li { margin-bottom: 0.2rem; }
|
||||
.stat-row { display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.9rem; margin: 0.4rem 0; }
|
||||
.stat-row span { padding: 0.1rem 0.4rem; background: var(--hover); border-radius: 4px; }
|
||||
.meta { font-size: 0.8em; opacity: 0.5; margin-top: 0.5rem; }
|
||||
.tag { background: var(--accent); color: var(--bg); padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.75em; margin-left: 0.3rem; }
|
||||
.search-box { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
|
||||
.search-box input {
|
||||
flex: 1; padding: 0.6rem 1rem; border: 2px solid var(--border);
|
||||
border-radius: 8px; background: var(--card); color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.search-box input:focus { border-color: var(--accent); outline: none; }
|
||||
.search-box button {
|
||||
padding: 0.6rem 1.2rem; background: var(--accent); color: var(--bg);
|
||||
border: none; border-radius: 8px; cursor: pointer; font-size: 1rem;
|
||||
}
|
||||
.card-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; margin: 1rem 0; }
|
||||
@media (min-width: 600px) { .card-grid { grid-template-columns: 1fr 1fr; } }
|
||||
.card {
|
||||
display: block; background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 1.2rem; text-decoration: none;
|
||||
color: var(--text); transition: border-color 0.2s;
|
||||
}
|
||||
.card:hover { border-color: var(--accent); background: var(--hover); text-decoration: none; }
|
||||
.card h3 { color: var(--accent); margin: 0 0 0.3rem 0; }
|
||||
.card p { font-size: 0.9em; margin: 0; opacity: 0.8; }
|
||||
.card .stats { font-size: 0.8em; margin-top: 0.5rem; opacity: 0.5; }
|
||||
.card .client { font-size: 0.85em; opacity: 0.65; margin-bottom: 0.3rem; font-style: italic; }
|
||||
.card h3 .tag { font-size: 0.65em; vertical-align: middle; margin-left: 0.4rem; }
|
||||
.triage-notice { background: var(--card); border-left: 4px solid var(--accent); padding: 0.6rem 1rem; border-radius: 4px; margin: 0.8rem 0; }
|
||||
.triage-warning { background: #fef3c7; color: #78350f; border-left: 4px solid #d97706; padding: 0.6rem 1rem; border-radius: 4px; margin: 0.8rem 0; }
|
||||
@media (prefers-color-scheme: dark) { .triage-warning { background: #451a03; color: #fde68a; } }
|
||||
.mem-tags { display: inline-flex; gap: 0.25rem; flex-wrap: wrap; vertical-align: middle; }
|
||||
.tag-badge { background: var(--accent); color: white; padding: 0.1rem 0.5rem; border-radius: 10px; font-size: 0.7rem; font-family: monospace; text-decoration: none; font-weight: 500; }
|
||||
.tag-badge:hover { opacity: 0.85; text-decoration: none; }
|
||||
.mem-expiry { font-size: 0.75rem; color: #d97706; font-style: italic; margin-left: 0.4rem; }
|
||||
@media (prefers-color-scheme: dark) { .mem-expiry { color: #fbbf24; } }
|
||||
/* Phase 6 C.2 — Emerging projects section */
|
||||
.emerging-intro { font-size: 0.9rem; opacity: 0.75; margin-bottom: 0.8rem; }
|
||||
.emerging-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
|
||||
.emerging-card { background: var(--card); border: 1px dashed var(--accent); border-radius: 8px; padding: 1rem; }
|
||||
.emerging-card h3 { margin: 0 0 0.3rem 0; color: var(--accent); font-family: monospace; font-size: 1rem; }
|
||||
.emerging-count { font-size: 0.8rem; opacity: 0.6; margin-bottom: 0.5rem; }
|
||||
.emerging-samples { font-size: 0.85rem; margin: 0.5rem 0; padding-left: 1.2rem; opacity: 0.8; }
|
||||
.emerging-samples li { margin-bottom: 0.25rem; }
|
||||
.btn-register-emerging { width: 100%; padding: 0.45rem 0.9rem; background: var(--accent); color: white; border: 1px solid var(--accent); border-radius: 4px; cursor: pointer; font-size: 0.88rem; font-weight: 500; margin-top: 0.5rem; }
|
||||
.btn-register-emerging:hover { opacity: 0.9; }
|
||||
</style>
|
||||
<script>
|
||||
async function registerEmerging(projectId) {
|
||||
if (!confirm(`Register "${projectId}" as a first-class project?\n\nThis creates:\n• /wiki/projects/${projectId} page\n• System map + gaps + killer queries\n• Triage + graduation support\n\nIngest root defaults to vault:incoming/projects/${projectId}/`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/admin/projects/register-emerging', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({project_id: projectId}),
|
||||
});
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
alert(data.message || `Registered ${projectId}`);
|
||||
window.location.reload();
|
||||
} else {
|
||||
const err = await r.text();
|
||||
alert(`Registration failed: ${r.status}\n${err.substring(0, 300)}`);
|
||||
}
|
||||
} catch (e) {
|
||||
alert(`Network error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{{nav}}
|
||||
{{body}}
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -18,15 +18,24 @@ violating the AtoCore trust hierarchy.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from atocore.models.database import get_connection
|
||||
from atocore.observability.logger import get_logger
|
||||
from atocore.projects.registry import resolve_project_name
|
||||
|
||||
log = get_logger("interactions")
|
||||
|
||||
# Stored timestamps use 'YYYY-MM-DD HH:MM:SS' (no timezone offset, UTC by
|
||||
# convention) so they sort lexically and compare cleanly with the SQLite
|
||||
# CURRENT_TIMESTAMP default. The since filter accepts ISO 8601 strings
|
||||
# (with 'T', optional 'Z' or +offset, optional fractional seconds) and
|
||||
# normalizes them to the storage format before the SQL comparison.
|
||||
_STORAGE_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Interaction:
|
||||
@@ -54,6 +63,7 @@ def record_interaction(
|
||||
chunks_used: list[str] | None = None,
|
||||
context_pack: dict | None = None,
|
||||
reinforce: bool = True,
|
||||
extract: bool = False,
|
||||
) -> Interaction:
|
||||
"""Persist a single interaction to the audit trail.
|
||||
|
||||
@@ -72,6 +82,13 @@ def record_interaction(
|
||||
if not prompt or not prompt.strip():
|
||||
raise ValueError("Interaction prompt must be non-empty")
|
||||
|
||||
# Canonicalize the project through the registry so an alias and
|
||||
# the canonical id store under the same bucket. Without this,
|
||||
# reinforcement and extraction (which both query by raw
|
||||
# interaction.project) would silently miss memories and create
|
||||
# candidates in the wrong project.
|
||||
project = resolve_project_name(project)
|
||||
|
||||
interaction_id = str(uuid.uuid4())
|
||||
# Store created_at explicitly so the same string lives in both the DB
|
||||
# column and the returned dataclass. SQLite's CURRENT_TIMESTAMP uses
|
||||
@@ -147,6 +164,30 @@ def record_interaction(
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
if extract and (response or response_summary):
|
||||
try:
|
||||
from atocore.memory.extractor import extract_candidates_from_interaction
|
||||
from atocore.memory.service import create_memory
|
||||
|
||||
candidates = extract_candidates_from_interaction(interaction)
|
||||
for candidate in candidates:
|
||||
try:
|
||||
create_memory(
|
||||
memory_type=candidate.memory_type,
|
||||
content=candidate.content,
|
||||
project=candidate.project,
|
||||
confidence=candidate.confidence,
|
||||
status="candidate",
|
||||
)
|
||||
except ValueError:
|
||||
pass # duplicate or validation error — skip silently
|
||||
except Exception as exc: # pragma: no cover - extraction must never block capture
|
||||
log.error(
|
||||
"extraction_failed_on_capture",
|
||||
interaction_id=interaction_id,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
return interaction
|
||||
|
||||
|
||||
@@ -159,9 +200,14 @@ def list_interactions(
|
||||
) -> list[Interaction]:
|
||||
"""List captured interactions, optionally filtered.
|
||||
|
||||
``since`` is an ISO timestamp string; only interactions created at or
|
||||
after that time are returned. ``limit`` is hard-capped at 500 to keep
|
||||
casual API listings cheap.
|
||||
``since`` accepts an ISO 8601 timestamp string (with ``T``, an
|
||||
optional ``Z`` or numeric offset, optional fractional seconds).
|
||||
The value is normalized to the storage format (UTC,
|
||||
``YYYY-MM-DD HH:MM:SS``) before the SQL comparison so external
|
||||
callers can pass any of the common ISO shapes without filter
|
||||
drift. ``project`` is canonicalized through the registry so an
|
||||
alias finds rows stored under the canonical project id.
|
||||
``limit`` is hard-capped at 500 to keep casual API listings cheap.
|
||||
"""
|
||||
if limit <= 0:
|
||||
return []
|
||||
@@ -172,7 +218,7 @@ def list_interactions(
|
||||
|
||||
if project:
|
||||
query += " AND project = ?"
|
||||
params.append(project)
|
||||
params.append(resolve_project_name(project))
|
||||
if session_id:
|
||||
query += " AND session_id = ?"
|
||||
params.append(session_id)
|
||||
@@ -181,7 +227,7 @@ def list_interactions(
|
||||
params.append(client)
|
||||
if since:
|
||||
query += " AND created_at >= ?"
|
||||
params.append(since)
|
||||
params.append(_normalize_since(since))
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
@@ -243,3 +289,41 @@ def _safe_json_dict(raw: str | None) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_since(since: str) -> str:
|
||||
"""Normalize an ISO 8601 ``since`` filter to the storage format.
|
||||
|
||||
Stored ``created_at`` values are ``YYYY-MM-DD HH:MM:SS`` (no
|
||||
timezone, UTC by convention). External callers naturally pass
|
||||
ISO 8601 with ``T`` separator, optional ``Z`` suffix, optional
|
||||
fractional seconds, and optional ``+HH:MM`` offsets. A naive
|
||||
string comparison between the two formats fails on the same
|
||||
day because the lexically-greater ``T`` makes any ISO value
|
||||
sort after any space-separated value.
|
||||
|
||||
This helper accepts the common ISO shapes plus the bare
|
||||
storage format and returns the storage format. On a parse
|
||||
failure it returns the input unchanged so the SQL comparison
|
||||
fails open (no rows match) instead of raising and breaking
|
||||
the listing endpoint.
|
||||
"""
|
||||
if not since:
|
||||
return since
|
||||
candidate = since.strip()
|
||||
# Python's fromisoformat understands trailing 'Z' from 3.11+ but
|
||||
# we replace it explicitly for safety against earlier shapes.
|
||||
if candidate.endswith("Z"):
|
||||
candidate = candidate[:-1] + "+00:00"
|
||||
try:
|
||||
dt = datetime.fromisoformat(candidate)
|
||||
except ValueError:
|
||||
# Already in storage format, or unparseable: best-effort
|
||||
# match the storage format with a regex; if that fails too,
|
||||
# return the raw input.
|
||||
if re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", since):
|
||||
return since
|
||||
return since
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt.strftime(_STORAGE_TIMESTAMP_FORMAT)
|
||||
|
||||
@@ -4,9 +4,11 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from atocore import __version__
|
||||
from atocore.api.routes import router
|
||||
import atocore.config as _config
|
||||
from atocore.context.project_state import init_project_state_schema
|
||||
from atocore.engineering.service import init_engineering_schema
|
||||
from atocore.ingestion.pipeline import get_source_status
|
||||
from atocore.models.database import init_db
|
||||
from atocore.observability.logger import get_logger, setup_logging
|
||||
@@ -28,6 +30,7 @@ async def lifespan(app: FastAPI):
|
||||
_config.ensure_runtime_dirs()
|
||||
init_db()
|
||||
init_project_state_schema()
|
||||
init_engineering_schema()
|
||||
log.info(
|
||||
"startup_ready",
|
||||
env=_config.settings.env,
|
||||
@@ -43,7 +46,7 @@ async def lifespan(app: FastAPI):
|
||||
app = FastAPI(
|
||||
title="AtoCore",
|
||||
description="Personal Context Engine for LLM interactions",
|
||||
version="0.1.0",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
200
src/atocore/memory/_dedup_prompt.py
Normal file
200
src/atocore/memory/_dedup_prompt.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""Shared LLM prompt + parser for memory dedup (Phase 7A).
|
||||
|
||||
Stdlib-only — must be importable from both the in-container service
|
||||
layer (when a user clicks "scan for duplicates" in the UI) and the
|
||||
host-side batch script (``scripts/memory_dedup.py``), which runs on
|
||||
Dalidou where the container's Python deps are not available.
|
||||
|
||||
The prompt instructs the model to draft a UNIFIED memory that
|
||||
preserves every specific detail from the sources. We never want a
|
||||
merge to lose information — if two memories disagree on a number, the
|
||||
merged content should surface both with context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
DEDUP_PROMPT_VERSION = "dedup-0.1.0"
|
||||
MAX_CONTENT_CHARS = 1000
|
||||
MAX_SOURCES = 8 # cluster size cap — bigger clusters are suspicious
|
||||
|
||||
SYSTEM_PROMPT = """You consolidate near-duplicate memories for AtoCore, a personal context engine.
|
||||
|
||||
Given 2-8 memories that a semantic-similarity scan flagged as likely duplicates, draft a UNIFIED replacement that preserves every specific detail from every source.
|
||||
|
||||
CORE PRINCIPLE: information never gets lost. If the sources disagree on a number, date, vendor, or spec, surface BOTH with attribution (e.g., "quoted at $3.2k on 2026-03-01, revised to $3.8k on 2026-04-10"). If one source is more specific than another, keep the specificity. If they say the same thing differently, pick the clearer wording.
|
||||
|
||||
YOU MUST:
|
||||
- Produce content under 500 characters that reads as a single coherent statement
|
||||
- Keep all project/vendor/person/part names that appear in any source
|
||||
- Keep all numbers, dates, and identifiers
|
||||
- Keep the strongest claim wording ("ratified", "decided", "committed") if any source has it
|
||||
- Propose domain_tags as a UNION of the sources' tags (lowercase, deduped, cap 6)
|
||||
- Return valid_until = latest non-null valid_until across sources, or null if any source has null (permanent beats transient)
|
||||
|
||||
REFUSE TO MERGE (return action="reject") if:
|
||||
- The memories are actually about DIFFERENT subjects that just share vocabulary (e.g., "p04 mirror" and "p05 mirror" — same project bucket means same project, but different components)
|
||||
- One memory CONTRADICTS another and you cannot reconcile them — flag for contradiction review instead
|
||||
- The sources span different time snapshots of a changing state that should stay as a timeline, not be collapsed
|
||||
|
||||
OUTPUT — raw JSON, no prose, no markdown fences:
|
||||
{
|
||||
"action": "merge" | "reject",
|
||||
"content": "the unified memory content",
|
||||
"memory_type": "knowledge|project|preference|adaptation|episodic|identity",
|
||||
"project": "project-slug or empty",
|
||||
"domain_tags": ["tag1", "tag2"],
|
||||
"confidence": 0.5,
|
||||
"reason": "one sentence explaining the merge (or the rejection)"
|
||||
}
|
||||
|
||||
On action=reject, still fill content with a short explanation and set confidence=0."""
|
||||
|
||||
|
||||
TIER2_SYSTEM_PROMPT = """You are the second-opinion reviewer for AtoCore's memory-consolidation pipeline.
|
||||
|
||||
A tier-1 model (cheaper, faster) already drafted a unified memory from N near-duplicate source memories. Your job is to either CONFIRM the merge (refining the content if you see a clearer phrasing) or OVERRIDE with action="reject" if the tier-1 missed something important.
|
||||
|
||||
You must be STRICTER than tier-1. Specifically, REJECT if:
|
||||
- The sources are about different subjects that share vocabulary (e.g., different components within the same project)
|
||||
- The tier-1 draft dropped specifics that existed in the sources (numbers, dates, vendors, people, part IDs)
|
||||
- One source contradicts another and the draft glossed over it
|
||||
- The sources span a timeline of a changing state (should be preserved as a sequence, not collapsed)
|
||||
|
||||
If you CONFIRM, you may polish the content — but preserve every specific from every source.
|
||||
|
||||
Same output schema as tier-1:
|
||||
{
|
||||
"action": "merge" | "reject",
|
||||
"content": "the unified memory content",
|
||||
"memory_type": "knowledge|project|preference|adaptation|episodic|identity",
|
||||
"project": "project-slug or empty",
|
||||
"domain_tags": ["tag1", "tag2"],
|
||||
"confidence": 0.5,
|
||||
"reason": "one sentence — what you confirmed or why you overrode"
|
||||
}
|
||||
|
||||
Raw JSON only, no prose, no markdown fences."""
|
||||
|
||||
|
||||
def build_tier2_user_message(sources: list[dict[str, Any]], tier1_verdict: dict[str, Any]) -> str:
|
||||
"""Format tier-2 review payload: same sources + tier-1's draft."""
|
||||
base = build_user_message(sources)
|
||||
draft_summary = (
|
||||
f"\n\n--- TIER-1 DRAFT (for your review) ---\n"
|
||||
f"action: {tier1_verdict.get('action')}\n"
|
||||
f"confidence: {tier1_verdict.get('confidence', 0):.2f}\n"
|
||||
f"proposed content: {(tier1_verdict.get('content') or '')[:600]}\n"
|
||||
f"proposed memory_type: {tier1_verdict.get('memory_type', '')}\n"
|
||||
f"proposed project: {tier1_verdict.get('project', '')}\n"
|
||||
f"proposed tags: {tier1_verdict.get('domain_tags', [])}\n"
|
||||
f"tier-1 reason: {tier1_verdict.get('reason', '')[:300]}\n"
|
||||
f"---\n\n"
|
||||
f"Return your JSON verdict now. Confirm or override."
|
||||
)
|
||||
return base.replace("Return the JSON object now.", "").rstrip() + draft_summary
|
||||
|
||||
|
||||
def build_user_message(sources: list[dict[str, Any]]) -> str:
|
||||
"""Format N source memories for the model to consolidate.
|
||||
|
||||
Each source dict should carry id, content, project, memory_type,
|
||||
domain_tags, confidence, valid_until, reference_count.
|
||||
"""
|
||||
lines = [f"You have {len(sources)} source memories in the same (project, memory_type) bucket:\n"]
|
||||
for i, src in enumerate(sources[:MAX_SOURCES], start=1):
|
||||
tags = src.get("domain_tags") or []
|
||||
if isinstance(tags, str):
|
||||
try:
|
||||
tags = json.loads(tags)
|
||||
except Exception:
|
||||
tags = []
|
||||
lines.append(
|
||||
f"--- Source {i} (id={src.get('id','?')[:8]}, "
|
||||
f"refs={src.get('reference_count',0)}, "
|
||||
f"conf={src.get('confidence',0):.2f}, "
|
||||
f"valid_until={src.get('valid_until') or 'permanent'}) ---"
|
||||
)
|
||||
lines.append(f"project: {src.get('project','')}")
|
||||
lines.append(f"type: {src.get('memory_type','')}")
|
||||
lines.append(f"tags: {tags}")
|
||||
lines.append(f"content: {(src.get('content') or '')[:MAX_CONTENT_CHARS]}")
|
||||
lines.append("")
|
||||
lines.append("Return the JSON object now.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_merge_verdict(raw_output: str) -> dict[str, Any] | None:
|
||||
"""Strip markdown fences / leading prose and return the parsed JSON
|
||||
object. Returns None on parse failure."""
|
||||
text = (raw_output or "").strip()
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
nl = text.find("\n")
|
||||
if nl >= 0:
|
||||
text = text[nl + 1:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
|
||||
if not text.lstrip().startswith("{"):
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
text = text[start:end + 1]
|
||||
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def normalize_merge_verdict(verdict: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Validate + normalize a raw merge verdict. Returns None if the
|
||||
verdict is unusable (no content, unknown action)."""
|
||||
action = str(verdict.get("action") or "").strip().lower()
|
||||
if action not in ("merge", "reject"):
|
||||
return None
|
||||
|
||||
content = str(verdict.get("content") or "").strip()
|
||||
if not content:
|
||||
return None
|
||||
|
||||
memory_type = str(verdict.get("memory_type") or "knowledge").strip().lower()
|
||||
project = str(verdict.get("project") or "").strip()
|
||||
|
||||
raw_tags = verdict.get("domain_tags") or []
|
||||
if isinstance(raw_tags, str):
|
||||
raw_tags = [t.strip() for t in raw_tags.split(",") if t.strip()]
|
||||
if not isinstance(raw_tags, list):
|
||||
raw_tags = []
|
||||
tags: list[str] = []
|
||||
for t in raw_tags[:6]:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
tt = t.strip().lower()
|
||||
if tt and tt not in tags:
|
||||
tags.append(tt)
|
||||
|
||||
try:
|
||||
confidence = float(verdict.get("confidence", 0.5))
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.5
|
||||
confidence = max(0.0, min(1.0, confidence))
|
||||
|
||||
reason = str(verdict.get("reason") or "").strip()[:500]
|
||||
|
||||
return {
|
||||
"action": action,
|
||||
"content": content[:1000],
|
||||
"memory_type": memory_type,
|
||||
"project": project,
|
||||
"domain_tags": tags,
|
||||
"confidence": confidence,
|
||||
"reason": reason,
|
||||
}
|
||||
256
src/atocore/memory/_llm_prompt.py
Normal file
256
src/atocore/memory/_llm_prompt.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""Shared LLM-extractor prompt + parser (stdlib-only).
|
||||
|
||||
R12: single source of truth for the system prompt, memory type set,
|
||||
size limits, and raw JSON parsing used by both paths that shell out
|
||||
to ``claude -p``:
|
||||
|
||||
- ``atocore.memory.extractor_llm`` (in-container extractor, wraps the
|
||||
parsed dicts in ``MemoryCandidate`` with registry-checked project
|
||||
attribution)
|
||||
- ``scripts/batch_llm_extract_live.py`` (host-side extractor, can't
|
||||
import the full atocore package because Dalidou's host Python lacks
|
||||
the container's deps; imports this module via ``sys.path``)
|
||||
|
||||
This module MUST stay stdlib-only. No ``atocore`` imports, no third-
|
||||
party packages. Callers apply their own project-attribution policy on
|
||||
top of the normalized dicts this module emits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
LLM_EXTRACTOR_VERSION = "llm-0.6.0" # bolder unknown-project tagging
|
||||
MAX_RESPONSE_CHARS = 8000
|
||||
MAX_PROMPT_CHARS = 2000
|
||||
MEMORY_TYPES = {"identity", "preference", "project", "episodic", "knowledge", "adaptation"}
|
||||
|
||||
SYSTEM_PROMPT = """You extract memory candidates from LLM conversation turns for a personal context engine called AtoCore.
|
||||
|
||||
AtoCore is the brain for Atomaste's engineering work. Known projects:
|
||||
p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, atocore,
|
||||
abb-space.
|
||||
|
||||
UNKNOWN PROJECT/TOOL DETECTION (important): when a memory is clearly
|
||||
about a named tool, product, project, or system that is NOT in the
|
||||
known list above, use a slugified version of that name as the project
|
||||
tag (e.g., "apm" for "Atomaste Part Manager", "foo-bar" for "Foo Bar
|
||||
System"). DO NOT default to a nearest registered match just because
|
||||
APM isn't listed — that's misattribution. The system's Living
|
||||
Taxonomy detector scans for these unregistered tags and surfaces them
|
||||
for one-click registration once they appear in ≥3 memories. Your job
|
||||
is to be honest about scope, not to squeeze everything into existing
|
||||
buckets.
|
||||
|
||||
Exception: if the memory is about a registered project that merely
|
||||
uses or integrates with an unknown tool (e.g., "p04 parts are missing
|
||||
materials in APM"), tag with the registered project (p04-gigabit) and
|
||||
mention the tool in content. Only use an unknown tool as the project
|
||||
tag when the tool itself is the primary subject.
|
||||
|
||||
Your job is to emit SIGNALS that matter for future context. Be aggressive:
|
||||
err on the side of capturing useful signal. Triage filters noise downstream.
|
||||
|
||||
WHAT TO EMIT (in order of importance):
|
||||
|
||||
1. PROJECT ACTIVITY — any mention of a project with context worth remembering:
|
||||
- "Schott quote received for ABB-Space" (event + project)
|
||||
- "Cédric asked about p06 firmware timing" (stakeholder event)
|
||||
- "Still waiting on Zygo lead-time from Nabeel" (blocker status)
|
||||
- "p05 vendor decision needs to happen this week" (action item)
|
||||
|
||||
2. DECISIONS AND CHOICES — anything that commits to a direction:
|
||||
- "Going with Zygo Verifire SV for p05" (decision)
|
||||
- "Dropping stitching from primary workflow" (design choice)
|
||||
- "USB SSD mandatory, not SD card" (architectural commitment)
|
||||
|
||||
3. DURABLE ENGINEERING INSIGHT — earned knowledge that generalizes:
|
||||
- "CTE gradient dominates WFE at F/1.2" (materials insight)
|
||||
- "Preston model breaks below 5N because contact assumption fails"
|
||||
- "m=1 coma NOT correctable by force modulation" (controls insight)
|
||||
Test: would a competent engineer NEED experience to know this?
|
||||
If it's textbook/google-findable, skip it.
|
||||
|
||||
4. STAKEHOLDER AND VENDOR EVENTS:
|
||||
- "Email sent to Nabeel 2026-04-13 asking for lead time"
|
||||
- "Meeting with Jason on Table 7 next Tuesday"
|
||||
- "Starspec wants updated CAD by Friday"
|
||||
|
||||
5. PREFERENCES AND ADAPTATIONS that shape how Antoine works:
|
||||
- "Antoine prefers OAuth over API keys"
|
||||
- "Extraction stays off the capture hot path"
|
||||
|
||||
WHAT TO SKIP:
|
||||
|
||||
- Pure conversational filler ("ok thanks", "let me check")
|
||||
- Instructional help content ("run this command", "here's how to...")
|
||||
- Obvious textbook facts anyone can google in 30 seconds
|
||||
- Session meta-chatter ("let me commit this", "deploy running")
|
||||
- Transient system state snapshots ("36 active memories right now")
|
||||
|
||||
CANDIDATE TYPES — choose the best fit:
|
||||
|
||||
- project — a fact, decision, or event specific to one named project
|
||||
- knowledge — durable engineering insight (use domain, not project)
|
||||
- preference — how Antoine works / wants things done
|
||||
- adaptation — a standing rule or adjustment to behavior
|
||||
- episodic — a stakeholder event or milestone worth remembering
|
||||
|
||||
DOMAINS for knowledge candidates (required when type=knowledge and project is empty):
|
||||
physics, materials, optics, mechanics, manufacturing, metrology,
|
||||
controls, software, math, finance, business
|
||||
|
||||
DOMAIN TAGS (Phase 3):
|
||||
Every candidate gets domain_tags — a lowercase list of topical keywords
|
||||
that describe the SUBJECT matter regardless of project. This is how
|
||||
cross-project retrieval works: a query about "optics" surfaces matches
|
||||
from p04 + p05 + p06 without naming each project.
|
||||
|
||||
Good tags: single lowercase words or hyphenated terms.
|
||||
Examples:
|
||||
- "ABB quote received for P04" → ["abb", "p04", "procurement", "optics"]
|
||||
- "USB SSD mandatory on polisher" → ["p06", "firmware", "storage"]
|
||||
- "CTE dominates WFE at F/1.2" → ["optics", "materials", "thermal"]
|
||||
- "Antoine prefers OAuth over API keys" → ["security", "auth", "preference"]
|
||||
|
||||
Tag 2-5 items. Use domain keywords (optics, thermal, firmware), project
|
||||
tokens when relevant (p04, abb), and lifecycle words (procurement, design,
|
||||
validation) as appropriate.
|
||||
|
||||
VALID_UNTIL (Phase 3):
|
||||
A memory can have an expiry date if it describes time-bounded truth.
|
||||
Use valid_until for:
|
||||
- Status snapshots: "current blocker is X" → valid_until = ~2 weeks out
|
||||
- Scheduled events: "meeting with vendor Friday" → valid_until = meeting date
|
||||
- Quotes with expiry: "quote valid until May 31"
|
||||
- Interim decisions pending ratification
|
||||
Leave empty (null) for:
|
||||
- Durable design decisions ("Option B selected")
|
||||
- Engineering insights ("CTE dominates at F/1.2")
|
||||
- Ratified requirements, architectural commitments
|
||||
Default = null (permanent). Format: ISO date "YYYY-MM-DD" or empty.
|
||||
|
||||
TRUST HIERARCHY:
|
||||
|
||||
- project-specific: set project to the project id, leave domain empty
|
||||
- domain knowledge: set domain, leave project empty
|
||||
- events/activity: use project, type=project or episodic
|
||||
- one conversation can produce MULTIPLE candidates — emit them all
|
||||
|
||||
OUTPUT RULES:
|
||||
|
||||
- Each candidate content under 250 characters, stands alone
|
||||
- Default confidence 0.5. Raise to 0.7 only for ratified/committed claims.
|
||||
- Raw JSON array, no prose, no markdown fences
|
||||
- Empty array [] is fine when the conversation has no durable signal
|
||||
|
||||
Each element:
|
||||
{"type": "project|knowledge|preference|adaptation|episodic", "content": "...", "project": "...", "domain": "", "confidence": 0.5, "domain_tags": ["tag1","tag2"], "valid_until": null}"""
|
||||
|
||||
|
||||
def build_user_message(prompt: str, response: str, project_hint: str) -> str:
|
||||
prompt_excerpt = (prompt or "")[:MAX_PROMPT_CHARS]
|
||||
response_excerpt = (response or "")[:MAX_RESPONSE_CHARS]
|
||||
return (
|
||||
f"PROJECT HINT (may be empty): {project_hint or ''}\n\n"
|
||||
f"USER PROMPT:\n{prompt_excerpt}\n\n"
|
||||
f"ASSISTANT RESPONSE:\n{response_excerpt}\n\n"
|
||||
"Return the JSON array now."
|
||||
)
|
||||
|
||||
|
||||
def parse_llm_json_array(raw_output: str) -> list[dict[str, Any]]:
|
||||
"""Strip markdown fences / leading prose and return the parsed JSON
|
||||
array as a list of raw dicts. Returns an empty list on any parse
|
||||
failure — callers decide whether to log."""
|
||||
text = (raw_output or "").strip()
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
nl = text.find("\n")
|
||||
if nl >= 0:
|
||||
text = text[nl + 1:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
|
||||
if not text or text == "[]":
|
||||
return []
|
||||
|
||||
if not text.lstrip().startswith("["):
|
||||
start = text.find("[")
|
||||
end = text.rfind("]")
|
||||
if start >= 0 and end > start:
|
||||
text = text[start:end + 1]
|
||||
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
return [item for item in parsed if isinstance(item, dict)]
|
||||
|
||||
|
||||
def normalize_candidate_item(item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Validate and normalize one raw model item into a candidate dict.
|
||||
|
||||
Returns None if the item fails basic validation (unknown type,
|
||||
empty content). Does NOT apply project-attribution policy — that's
|
||||
the caller's job, since the registry-check differs between the
|
||||
in-container path and the host path.
|
||||
|
||||
Output keys: type, content, project (raw model value), domain,
|
||||
confidence.
|
||||
"""
|
||||
mem_type = str(item.get("type") or "").strip().lower()
|
||||
content = str(item.get("content") or "").strip()
|
||||
if mem_type not in MEMORY_TYPES or not content:
|
||||
return None
|
||||
|
||||
model_project = str(item.get("project") or "").strip()
|
||||
domain = str(item.get("domain") or "").strip().lower()
|
||||
|
||||
try:
|
||||
confidence = float(item.get("confidence", 0.5))
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.5
|
||||
confidence = max(0.0, min(1.0, confidence))
|
||||
|
||||
if domain and not model_project:
|
||||
content = f"[{domain}] {content}"
|
||||
|
||||
# Phase 3: domain_tags + valid_until
|
||||
raw_tags = item.get("domain_tags") or []
|
||||
if isinstance(raw_tags, str):
|
||||
# Tolerate comma-separated string fallback
|
||||
raw_tags = [t.strip() for t in raw_tags.split(",") if t.strip()]
|
||||
if not isinstance(raw_tags, list):
|
||||
raw_tags = []
|
||||
domain_tags = []
|
||||
for t in raw_tags[:10]: # cap at 10
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
tag = t.strip().lower()
|
||||
if tag and tag not in domain_tags:
|
||||
domain_tags.append(tag)
|
||||
|
||||
valid_until = item.get("valid_until")
|
||||
if valid_until is not None:
|
||||
valid_until = str(valid_until).strip()
|
||||
# Accept ISO date "YYYY-MM-DD" or full timestamp; empty/"null" → none
|
||||
if valid_until.lower() in ("", "null", "none", "permanent"):
|
||||
valid_until = ""
|
||||
else:
|
||||
valid_until = ""
|
||||
|
||||
return {
|
||||
"type": mem_type,
|
||||
"content": content[:1000],
|
||||
"project": model_project,
|
||||
"domain": domain,
|
||||
"confidence": confidence,
|
||||
"domain_tags": domain_tags,
|
||||
"valid_until": valid_until,
|
||||
}
|
||||
158
src/atocore/memory/_tag_canon_prompt.py
Normal file
158
src/atocore/memory/_tag_canon_prompt.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Shared LLM prompt + parser for tag canonicalization (Phase 7C).
|
||||
|
||||
Stdlib-only, importable from both the in-container service layer and the
|
||||
host-side batch script that shells out to ``claude -p``.
|
||||
|
||||
The prompt instructs the model to propose a map of domain_tag aliases
|
||||
to their canonical form. Confidence is key here — we AUTO-APPLY high-
|
||||
confidence aliases; low-confidence go to human review. Over-merging
|
||||
distinct concepts ("optics" vs "optical" — sometimes equivalent,
|
||||
sometimes not) destroys cross-cutting retrieval, so the model is
|
||||
instructed to err conservative.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
TAG_CANON_PROMPT_VERSION = "tagcanon-0.1.0"
|
||||
MAX_TAGS_IN_PROMPT = 100
|
||||
|
||||
SYSTEM_PROMPT = """You canonicalize domain tags for AtoCore's memory layer.
|
||||
|
||||
Input: a distribution of lowercase domain tags (keyword → usage count across active memories). Examples: "firmware: 23", "fw: 5", "firmware-control: 3", "optics: 18", "optical: 2".
|
||||
|
||||
Your job: identify aliases — distinct strings that refer to the SAME concept — and map them to a single canonical form. The canonical should be the clearest / most-used / most-descriptive variant.
|
||||
|
||||
STRICT RULES:
|
||||
|
||||
1. ONLY propose aliases that are UNAMBIGUOUSLY equivalent. Examples:
|
||||
- "fw" → "firmware" (abbreviation)
|
||||
- "firmware-control" → "firmware" (compound narrowing — only if usage context makes it clear the narrower one is never used to DISTINGUISH from firmware-in-general)
|
||||
- "py" → "python"
|
||||
- "ml" → "machine-learning"
|
||||
Do NOT merge:
|
||||
- "optics" vs "optical" — these CAN diverge ("optics" = subsystem/product domain; "optical" = adjective used in non-optics contexts)
|
||||
- "p04" vs "p04-gigabit" — project ids are their own namespace, never canonicalize
|
||||
- "thermal" vs "temperature" — related but distinct
|
||||
- Anything where you're not sure — skip it, human review will catch real aliases next week
|
||||
|
||||
2. Confidence scale:
|
||||
0.9+ obvious abbreviation, very high usage disparity, no plausible alternative meaning
|
||||
0.7-0.9 likely alias, one-word-diff or standard contraction
|
||||
0.5-0.7 plausible but requires context — low count on alias side
|
||||
<0.5 DO NOT PROPOSE — if you're under 0.5, skip the pair entirely
|
||||
AtoCore auto-applies aliases at confidence >= 0.8; anything below goes to human review.
|
||||
|
||||
3. The CANONICAL must actually appear in the input list (don't invent a new term).
|
||||
|
||||
4. Never propose `alias == canonical`. Never propose circular mappings.
|
||||
|
||||
5. Project tags (p04, p05, p06, abb-space, atomizer-v2, atocore, apm) are OFF LIMITS — they are project identifiers, not concepts. Leave them alone entirely.
|
||||
|
||||
OUTPUT — raw JSON, no prose, no markdown fences:
|
||||
{
|
||||
"aliases": [
|
||||
{"alias": "fw", "canonical": "firmware", "confidence": 0.95, "reason": "fw is a standard abbreviation of firmware; 5 uses vs 23"},
|
||||
{"alias": "ml", "canonical": "machine-learning", "confidence": 0.90, "reason": "ml is the universal abbreviation"}
|
||||
]
|
||||
}
|
||||
|
||||
Empty aliases list is fine if nothing in the distribution is a clear alias. Err conservative — one false merge can pollute retrieval for hundreds of memories."""
|
||||
|
||||
|
||||
def build_user_message(tag_distribution: dict[str, int]) -> str:
|
||||
"""Format the tag distribution for the model.
|
||||
|
||||
Limited to MAX_TAGS_IN_PROMPT entries, sorted by count descending
|
||||
so high-usage tags appear first (the LLM uses them as anchor points
|
||||
for canonical selection).
|
||||
"""
|
||||
if not tag_distribution:
|
||||
return "Empty tag distribution — return {\"aliases\": []}."
|
||||
|
||||
sorted_tags = sorted(tag_distribution.items(), key=lambda x: x[1], reverse=True)
|
||||
top = sorted_tags[:MAX_TAGS_IN_PROMPT]
|
||||
lines = [f"{tag}: {count}" for tag, count in top]
|
||||
return (
|
||||
f"Tag distribution across {sum(tag_distribution.values())} total tag references "
|
||||
f"(showing top {len(top)} of {len(tag_distribution)} unique tags):\n\n"
|
||||
+ "\n".join(lines)
|
||||
+ "\n\nReturn the JSON aliases map now. Only propose UNAMBIGUOUS equivalents."
|
||||
)
|
||||
|
||||
|
||||
def parse_canon_output(raw_output: str) -> list[dict[str, Any]]:
|
||||
"""Strip markdown fences / prose and return the parsed aliases list."""
|
||||
text = (raw_output or "").strip()
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
nl = text.find("\n")
|
||||
if nl >= 0:
|
||||
text = text[nl + 1:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
|
||||
if not text.lstrip().startswith("{"):
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
text = text[start:end + 1]
|
||||
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
return []
|
||||
aliases = parsed.get("aliases") or []
|
||||
if not isinstance(aliases, list):
|
||||
return []
|
||||
return [a for a in aliases if isinstance(a, dict)]
|
||||
|
||||
|
||||
# Project tokens that must never be canonicalized — they're project ids,
|
||||
# not concepts. Keep this list in sync with the registered projects.
|
||||
# Safe to be over-inclusive; extra entries just skip canonicalization.
|
||||
PROTECTED_PROJECT_TOKENS = frozenset({
|
||||
"p04", "p04-gigabit",
|
||||
"p05", "p05-interferometer",
|
||||
"p06", "p06-polisher",
|
||||
"p08", "abb-space",
|
||||
"atomizer", "atomizer-v2",
|
||||
"atocore", "apm",
|
||||
})
|
||||
|
||||
|
||||
def normalize_alias_item(item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Validate one raw alias proposal. Returns None if unusable.
|
||||
|
||||
Filters: non-strings, empty strings, identity mappings, protected
|
||||
project tokens on either side.
|
||||
"""
|
||||
alias = str(item.get("alias") or "").strip().lower()
|
||||
canonical = str(item.get("canonical") or "").strip().lower()
|
||||
if not alias or not canonical:
|
||||
return None
|
||||
if alias == canonical:
|
||||
return None
|
||||
if alias in PROTECTED_PROJECT_TOKENS or canonical in PROTECTED_PROJECT_TOKENS:
|
||||
return None
|
||||
|
||||
try:
|
||||
confidence = float(item.get("confidence", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.0
|
||||
confidence = max(0.0, min(1.0, confidence))
|
||||
|
||||
reason = str(item.get("reason") or "").strip()[:300]
|
||||
|
||||
return {
|
||||
"alias": alias,
|
||||
"canonical": canonical,
|
||||
"confidence": confidence,
|
||||
"reason": reason,
|
||||
}
|
||||
255
src/atocore/memory/extractor_llm.py
Normal file
255
src/atocore/memory/extractor_llm.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""LLM-assisted candidate-memory extraction via the Claude Code CLI.
|
||||
|
||||
Day 4 of the 2026-04-11 mini-phase: the rule-based extractor hit 0%
|
||||
recall against real conversational claude-code captures (Day 2 baseline
|
||||
scorecard in ``scripts/eval_data/extractor_labels_2026-04-11.json``),
|
||||
with false negatives spread across 5 distinct miss classes. A single
|
||||
rule expansion cannot close that gap, so this module adds an optional
|
||||
LLM-assisted mode that shells out to the ``claude -p`` (Claude Code
|
||||
non-interactive) CLI with a focused extraction system prompt. That
|
||||
path reuses the user's existing Claude.ai OAuth credentials — no API
|
||||
key anywhere, per the 2026-04-11 decision.
|
||||
|
||||
Trust rules carried forward from the rule-based extractor:
|
||||
|
||||
- Candidates are NEVER auto-promoted. Caller persists with
|
||||
``status="candidate"`` and a human reviews via the triage CLI.
|
||||
- This path is additive. The rule-based extractor keeps working
|
||||
exactly as before; callers opt in by importing this module.
|
||||
- Extraction stays off the capture hot path — this is batch / manual
|
||||
only, per the 2026-04-11 decision.
|
||||
- Failure is silent. Missing CLI, non-zero exit, malformed JSON,
|
||||
timeout — all return an empty list and log an error. Never raises
|
||||
into the caller; the capture audit trail must not break on an
|
||||
optional side effect.
|
||||
|
||||
Configuration:
|
||||
|
||||
- Requires the ``claude`` CLI on PATH (``claude --version`` should work).
|
||||
- ``ATOCORE_LLM_EXTRACTOR_MODEL`` overrides the model alias (default
|
||||
``sonnet``).
|
||||
- ``ATOCORE_LLM_EXTRACTOR_TIMEOUT_S`` overrides the per-call timeout
|
||||
(default 90 seconds — first invocation is slow because Node.js
|
||||
startup plus OAuth check is non-trivial).
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- We run ``claude -p`` with ``--model <alias>``,
|
||||
``--append-system-prompt`` for the extraction instructions,
|
||||
``--no-session-persistence`` so we don't pollute session history,
|
||||
and ``--disable-slash-commands`` so stray ``/foo`` in an extracted
|
||||
response never triggers something.
|
||||
- The CLI is invoked from a temp working directory so it does not
|
||||
auto-discover ``CLAUDE.md`` / ``DEV-LEDGER.md`` / ``AGENTS.md``
|
||||
from the repo root. We want a bare extraction context, not the
|
||||
full project briefing. We can't use ``--bare`` because that
|
||||
forces API-key auth; the temp-cwd trick is the lightest way to
|
||||
keep OAuth auth while skipping project context loading.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
|
||||
from atocore.interactions.service import Interaction
|
||||
from atocore.memory._llm_prompt import (
|
||||
LLM_EXTRACTOR_VERSION,
|
||||
SYSTEM_PROMPT as _SYSTEM_PROMPT,
|
||||
build_user_message,
|
||||
normalize_candidate_item,
|
||||
parse_llm_json_array,
|
||||
)
|
||||
from atocore.memory.extractor import MemoryCandidate
|
||||
from atocore.observability.logger import get_logger
|
||||
|
||||
log = get_logger("extractor_llm")
|
||||
|
||||
DEFAULT_MODEL = os.environ.get("ATOCORE_LLM_EXTRACTOR_MODEL", "sonnet")
|
||||
DEFAULT_TIMEOUT_S = float(os.environ.get("ATOCORE_LLM_EXTRACTOR_TIMEOUT_S", "90"))
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMExtractionResult:
|
||||
candidates: list[MemoryCandidate]
|
||||
raw_output: str
|
||||
error: str = ""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _sandbox_cwd() -> str:
|
||||
"""Return a stable temp directory for ``claude -p`` invocations.
|
||||
|
||||
We want the CLI to run from a directory that does NOT contain
|
||||
``CLAUDE.md`` / ``DEV-LEDGER.md`` / ``AGENTS.md``, so every
|
||||
extraction call starts with a clean context instead of the full
|
||||
AtoCore project briefing. Cached so the directory persists for
|
||||
the lifetime of the process.
|
||||
"""
|
||||
return tempfile.mkdtemp(prefix="ato-llm-extract-")
|
||||
|
||||
|
||||
def _cli_available() -> bool:
|
||||
return shutil.which("claude") is not None
|
||||
|
||||
|
||||
def extract_candidates_llm(
|
||||
interaction: Interaction,
|
||||
model: str | None = None,
|
||||
timeout_s: float | None = None,
|
||||
) -> list[MemoryCandidate]:
|
||||
"""Run the LLM-assisted extractor against one interaction.
|
||||
|
||||
Returns a list of ``MemoryCandidate`` objects, empty on any
|
||||
failure path. The caller is responsible for persistence.
|
||||
"""
|
||||
return extract_candidates_llm_verbose(
|
||||
interaction,
|
||||
model=model,
|
||||
timeout_s=timeout_s,
|
||||
).candidates
|
||||
|
||||
|
||||
def extract_candidates_llm_verbose(
|
||||
interaction: Interaction,
|
||||
model: str | None = None,
|
||||
timeout_s: float | None = None,
|
||||
) -> LLMExtractionResult:
|
||||
"""Like ``extract_candidates_llm`` but also returns the raw
|
||||
subprocess output and any error encountered, for eval / debugging.
|
||||
"""
|
||||
if not _cli_available():
|
||||
return LLMExtractionResult(
|
||||
candidates=[],
|
||||
raw_output="",
|
||||
error="claude_cli_missing",
|
||||
)
|
||||
|
||||
response_text = (interaction.response or "").strip()
|
||||
if not response_text:
|
||||
return LLMExtractionResult(candidates=[], raw_output="", error="empty_response")
|
||||
|
||||
user_message = build_user_message(
|
||||
interaction.prompt or "",
|
||||
response_text,
|
||||
interaction.project or "",
|
||||
)
|
||||
|
||||
args = [
|
||||
"claude",
|
||||
"-p",
|
||||
"--model",
|
||||
model or DEFAULT_MODEL,
|
||||
"--append-system-prompt",
|
||||
_SYSTEM_PROMPT,
|
||||
"--disable-slash-commands",
|
||||
user_message,
|
||||
]
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s or DEFAULT_TIMEOUT_S,
|
||||
cwd=_sandbox_cwd(),
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
log.error("llm_extractor_timeout", interaction_id=interaction.id)
|
||||
return LLMExtractionResult(candidates=[], raw_output="", error="timeout")
|
||||
except Exception as exc: # pragma: no cover - unexpected subprocess failure
|
||||
log.error("llm_extractor_subprocess_failed", error=str(exc))
|
||||
return LLMExtractionResult(candidates=[], raw_output="", error=f"subprocess_error: {exc}")
|
||||
|
||||
if completed.returncode != 0:
|
||||
log.error(
|
||||
"llm_extractor_nonzero_exit",
|
||||
interaction_id=interaction.id,
|
||||
returncode=completed.returncode,
|
||||
stderr_prefix=(completed.stderr or "")[:200],
|
||||
)
|
||||
return LLMExtractionResult(
|
||||
candidates=[],
|
||||
raw_output=completed.stdout or "",
|
||||
error=f"exit_{completed.returncode}",
|
||||
)
|
||||
|
||||
raw_output = (completed.stdout or "").strip()
|
||||
candidates = _parse_candidates(raw_output, interaction)
|
||||
log.info(
|
||||
"llm_extractor_done",
|
||||
interaction_id=interaction.id,
|
||||
candidate_count=len(candidates),
|
||||
model=model or DEFAULT_MODEL,
|
||||
)
|
||||
return LLMExtractionResult(candidates=candidates, raw_output=raw_output)
|
||||
|
||||
|
||||
def _parse_candidates(raw_output: str, interaction: Interaction) -> list[MemoryCandidate]:
|
||||
"""Parse the model's JSON output into MemoryCandidate objects.
|
||||
|
||||
Shared stripping + per-item validation live in
|
||||
``atocore.memory._llm_prompt``. This function adds the container-
|
||||
only R9 project attribution: registry-check model_project and fall
|
||||
back to the interaction scope when set.
|
||||
"""
|
||||
raw_items = parse_llm_json_array(raw_output)
|
||||
if not raw_items and raw_output.strip() not in ("", "[]"):
|
||||
log.error("llm_extractor_parse_failed", raw_prefix=raw_output[:120])
|
||||
|
||||
results: list[MemoryCandidate] = []
|
||||
for raw_item in raw_items:
|
||||
normalized = normalize_candidate_item(raw_item)
|
||||
if normalized is None:
|
||||
continue
|
||||
|
||||
model_project = normalized["project"]
|
||||
# R9 trust hierarchy: interaction scope wins; else registry-
|
||||
# resolve the model's tag; else keep the model's tag so auto-
|
||||
# triage can surface unregistered projects.
|
||||
if interaction.project:
|
||||
project = interaction.project
|
||||
elif model_project:
|
||||
try:
|
||||
from atocore.projects.registry import (
|
||||
load_project_registry,
|
||||
resolve_project_name,
|
||||
)
|
||||
|
||||
registered_ids = {p.project_id for p in load_project_registry()}
|
||||
resolved = resolve_project_name(model_project)
|
||||
if resolved in registered_ids:
|
||||
project = resolved
|
||||
else:
|
||||
project = model_project
|
||||
log.info(
|
||||
"unregistered_project_detected",
|
||||
model_project=model_project,
|
||||
interaction_id=interaction.id,
|
||||
)
|
||||
except Exception:
|
||||
project = model_project
|
||||
else:
|
||||
project = ""
|
||||
|
||||
content = normalized["content"]
|
||||
results.append(
|
||||
MemoryCandidate(
|
||||
memory_type=normalized["type"],
|
||||
content=content,
|
||||
rule="llm_extraction",
|
||||
source_span=content[:200],
|
||||
project=project,
|
||||
confidence=normalized["confidence"],
|
||||
source_interaction_id=interaction.id,
|
||||
extractor_version=LLM_EXTRACTOR_VERSION,
|
||||
)
|
||||
)
|
||||
return results
|
||||
@@ -8,10 +8,11 @@ given memory, without ever promoting anything new into trusted state.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- Matching is intentionally simple and explainable:
|
||||
* normalize both sides (lowercase, collapse whitespace)
|
||||
* require the normalized memory content (or its first 80 chars) to
|
||||
appear as a substring in the normalized response
|
||||
- Matching uses token-overlap: tokenize both sides (lowercase, stem,
|
||||
drop stop words), then check whether >= 70 % of the memory's content
|
||||
tokens appear in the response token set. This handles natural
|
||||
paraphrases (e.g. "prefers" vs "prefer", "because history" vs
|
||||
"because the history") that substring matching missed.
|
||||
- Candidates and invalidated memories are NEVER considered — reinforcement
|
||||
must not revive history.
|
||||
- Reinforcement is capped at 1.0 and monotonically non-decreasing.
|
||||
@@ -43,9 +44,21 @@ log = get_logger("reinforcement")
|
||||
# memories like "prefers Python".
|
||||
_MIN_MEMORY_CONTENT_LENGTH = 12
|
||||
|
||||
# When a memory's content is very long, match on its leading window only
|
||||
# to avoid punishing small paraphrases further into the body.
|
||||
_MATCH_WINDOW_CHARS = 80
|
||||
# Token-overlap matching constants.
|
||||
_STOP_WORDS: frozenset[str] = frozenset({
|
||||
"the", "a", "an", "and", "or", "of", "to", "is", "was",
|
||||
"that", "this", "with", "for", "from", "into",
|
||||
})
|
||||
_MATCH_THRESHOLD = 0.70
|
||||
|
||||
# Long memories can't realistically hit 70% overlap through organic
|
||||
# paraphrase — a 40-token memory would need 28 stemmed tokens echoed
|
||||
# verbatim. Above this token count the matcher switches to an absolute
|
||||
# overlap floor plus a softer fraction floor so paragraph-length memories
|
||||
# still reinforce when the response genuinely uses them.
|
||||
_LONG_MEMORY_TOKEN_COUNT = 15
|
||||
_LONG_MODE_MIN_OVERLAP = 12
|
||||
_LONG_MODE_MIN_FRACTION = 0.35
|
||||
|
||||
DEFAULT_CONFIDENCE_DELTA = 0.02
|
||||
|
||||
@@ -144,12 +157,85 @@ def _normalize(text: str) -> str:
|
||||
return collapsed.strip()
|
||||
|
||||
|
||||
def _stem(word: str) -> str:
|
||||
"""Aggressive suffix-folding so inflected forms collapse.
|
||||
|
||||
Handles trailing ``ing``, ``ed``, and ``s`` — good enough for
|
||||
reinforcement matching without pulling in nltk/snowball.
|
||||
"""
|
||||
# Order matters: try longest suffix first.
|
||||
if word.endswith("ing") and len(word) >= 6:
|
||||
return word[:-3]
|
||||
if word.endswith("ed") and len(word) > 4:
|
||||
stem = word[:-2]
|
||||
# "preferred" → "preferr" → "prefer" (doubled consonant before -ed)
|
||||
if len(stem) >= 3 and stem[-1] == stem[-2]:
|
||||
stem = stem[:-1]
|
||||
return stem
|
||||
if word.endswith("s") and len(word) > 3:
|
||||
return word[:-1]
|
||||
return word
|
||||
|
||||
|
||||
def _tokenize(text: str) -> set[str]:
|
||||
"""Split normalized text into a stemmed token set.
|
||||
|
||||
Strips punctuation, drops words shorter than 3 chars and stop
|
||||
words. Hyphenated and slash-separated identifiers
|
||||
(``polisher-control``, ``twyman-green``, ``2-projects/interferometer``)
|
||||
produce both the full form AND each sub-token, so a query for
|
||||
"polisher control" can match a memory that wrote
|
||||
"polisher-control" without forcing callers to guess the exact
|
||||
hyphenation.
|
||||
"""
|
||||
tokens: set[str] = set()
|
||||
for raw in text.split():
|
||||
word = raw.strip(".,;:!?\"'()[]{}-/")
|
||||
if not word:
|
||||
continue
|
||||
_add_token(tokens, word)
|
||||
# Also add sub-tokens split on internal '-' or '/' so
|
||||
# hyphenated identifiers match queries that don't hyphenate.
|
||||
if "-" in word or "/" in word:
|
||||
for sub in re.split(r"[-/]+", word):
|
||||
_add_token(tokens, sub)
|
||||
return tokens
|
||||
|
||||
|
||||
def _add_token(tokens: set[str], word: str) -> None:
|
||||
if len(word) < 3:
|
||||
return
|
||||
if word in _STOP_WORDS:
|
||||
return
|
||||
tokens.add(_stem(word))
|
||||
|
||||
|
||||
def _memory_matches(memory_content: str, normalized_response: str) -> bool:
|
||||
"""Return True if the memory content appears in the response."""
|
||||
"""Return True if enough of the memory's tokens appear in the response.
|
||||
|
||||
Dual-mode token overlap:
|
||||
- Short memories (<= _LONG_MEMORY_TOKEN_COUNT stems): require
|
||||
>= 70 % of memory tokens echoed.
|
||||
- Long memories (paragraphs): require an absolute floor of
|
||||
_LONG_MODE_MIN_OVERLAP distinct stems echoed AND a softer
|
||||
fraction of _LONG_MODE_MIN_FRACTION, so organic paraphrase
|
||||
of a real project memory can reinforce without the response
|
||||
quoting the paragraph verbatim.
|
||||
"""
|
||||
if not memory_content:
|
||||
return False
|
||||
normalized_memory = _normalize(memory_content)
|
||||
if len(normalized_memory) < _MIN_MEMORY_CONTENT_LENGTH:
|
||||
return False
|
||||
window = normalized_memory[:_MATCH_WINDOW_CHARS]
|
||||
return window in normalized_response
|
||||
memory_tokens = _tokenize(normalized_memory)
|
||||
if not memory_tokens:
|
||||
return False
|
||||
response_tokens = _tokenize(normalized_response)
|
||||
overlap = memory_tokens & response_tokens
|
||||
fraction = len(overlap) / len(memory_tokens)
|
||||
if len(memory_tokens) <= _LONG_MEMORY_TOKEN_COUNT:
|
||||
return fraction >= _MATCH_THRESHOLD
|
||||
return (
|
||||
len(overlap) >= _LONG_MODE_MIN_OVERLAP
|
||||
and fraction >= _LONG_MODE_MIN_FRACTION
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
88
src/atocore/memory/similarity.py
Normal file
88
src/atocore/memory/similarity.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Phase 7A (Memory Consolidation): semantic similarity helpers.
|
||||
|
||||
Thin wrapper over ``atocore.retrieval.embeddings`` that exposes
|
||||
pairwise + batch cosine similarity on normalized embeddings. Used by
|
||||
the dedup detector to cluster near-duplicate active memories.
|
||||
|
||||
Embeddings from ``embed_texts()`` are already L2-normalized, so cosine
|
||||
similarity reduces to a dot product — no extra normalization needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from atocore.retrieval.embeddings import embed_texts
|
||||
|
||||
|
||||
def _dot(a: list[float], b: list[float]) -> float:
|
||||
return sum(x * y for x, y in zip(a, b))
|
||||
|
||||
|
||||
def cosine(a: list[float], b: list[float]) -> float:
|
||||
"""Cosine similarity on already-normalized vectors. Clamped to [0,1]
|
||||
(embeddings use paraphrase-multilingual-MiniLM which is unit-norm,
|
||||
and we never want negative values leaking into thresholds)."""
|
||||
return max(0.0, min(1.0, _dot(a, b)))
|
||||
|
||||
|
||||
def compute_memory_similarity(text_a: str, text_b: str) -> float:
|
||||
"""Return cosine similarity of two memory contents in [0,1].
|
||||
|
||||
Convenience helper for one-off checks + tests. For batch work (the
|
||||
dedup detector), use ``embed_texts()`` directly and compute the
|
||||
similarity matrix yourself to avoid re-embedding shared texts.
|
||||
"""
|
||||
if not text_a or not text_b:
|
||||
return 0.0
|
||||
vecs = embed_texts([text_a, text_b])
|
||||
return cosine(vecs[0], vecs[1])
|
||||
|
||||
|
||||
def similarity_matrix(texts: list[str]) -> list[list[float]]:
|
||||
"""N×N cosine similarity matrix. Diagonal is 1.0, symmetric."""
|
||||
if not texts:
|
||||
return []
|
||||
vecs = embed_texts(texts)
|
||||
n = len(vecs)
|
||||
matrix = [[0.0] * n for _ in range(n)]
|
||||
for i in range(n):
|
||||
matrix[i][i] = 1.0
|
||||
for j in range(i + 1, n):
|
||||
s = cosine(vecs[i], vecs[j])
|
||||
matrix[i][j] = s
|
||||
matrix[j][i] = s
|
||||
return matrix
|
||||
|
||||
|
||||
def cluster_by_threshold(texts: list[str], threshold: float) -> list[list[int]]:
|
||||
"""Greedy transitive clustering: if sim(i,j) >= threshold, merge.
|
||||
|
||||
Returns a list of clusters, each a list of indices into ``texts``.
|
||||
Singletons are included. Used by the dedup detector to collapse
|
||||
A~B~C into one merge proposal rather than three pair proposals.
|
||||
"""
|
||||
if not texts:
|
||||
return []
|
||||
matrix = similarity_matrix(texts)
|
||||
n = len(texts)
|
||||
parent = list(range(n))
|
||||
|
||||
def find(x: int) -> int:
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
def union(x: int, y: int) -> None:
|
||||
rx, ry = find(x), find(y)
|
||||
if rx != ry:
|
||||
parent[rx] = ry
|
||||
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
if matrix[i][j] >= threshold:
|
||||
union(i, j)
|
||||
|
||||
groups: dict[int, list[int]] = {}
|
||||
for i in range(n):
|
||||
groups.setdefault(find(i), []).append(i)
|
||||
return list(groups.values())
|
||||
@@ -71,14 +71,18 @@ CREATE TABLE IF NOT EXISTS interactions (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes that reference columns guaranteed to exist since the first
|
||||
-- release ship here. Indexes that reference columns added by later
|
||||
-- migrations (memories.project, interactions.project,
|
||||
-- interactions.session_id) are created inside _apply_migrations AFTER
|
||||
-- the corresponding ALTER TABLE, NOT here. Creating them here would
|
||||
-- fail on upgrade from a pre-migration schema because CREATE TABLE
|
||||
-- IF NOT EXISTS is a no-op on an existing table, so the new columns
|
||||
-- wouldn't be added before the CREATE INDEX runs.
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_document ON source_chunks(document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(memory_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_status ON memories(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_project ON interactions(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_project_name ON interactions(project);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_session ON interactions(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_created_at ON interactions(created_at);
|
||||
"""
|
||||
|
||||
|
||||
@@ -115,6 +119,111 @@ def _apply_migrations(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_memories_last_referenced ON memories(last_referenced_at)"
|
||||
)
|
||||
|
||||
# Phase 3 (Auto-Organization V1): domain tags + expiry.
|
||||
# domain_tags is a JSON array of lowercase strings (optics, mechanics,
|
||||
# firmware, business, etc.) inferred by the LLM during triage. Used for
|
||||
# cross-project retrieval: a query about "optics" can surface matches from
|
||||
# p04 + p05 + p06 without knowing all the project names.
|
||||
# valid_until is an ISO UTC timestamp beyond which the memory is
|
||||
# considered stale. get_memories_for_context filters these out of context
|
||||
# packs automatically so ephemeral facts (status snapshots, weekly counts)
|
||||
# don't pollute grounding once they've aged out.
|
||||
if not _column_exists(conn, "memories", "domain_tags"):
|
||||
conn.execute("ALTER TABLE memories ADD COLUMN domain_tags TEXT DEFAULT '[]'")
|
||||
if not _column_exists(conn, "memories", "valid_until"):
|
||||
conn.execute("ALTER TABLE memories ADD COLUMN valid_until DATETIME")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_memories_valid_until ON memories(valid_until)"
|
||||
)
|
||||
|
||||
# Phase 5 (Engineering V1): when a memory graduates to an entity, we
|
||||
# keep the memory row as an immutable historical pointer. The forward
|
||||
# pointer lets downstream code follow "what did this memory become?"
|
||||
# without having to join through source_refs.
|
||||
if not _column_exists(conn, "memories", "graduated_to_entity_id"):
|
||||
conn.execute("ALTER TABLE memories ADD COLUMN graduated_to_entity_id TEXT")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_memories_graduated ON memories(graduated_to_entity_id)"
|
||||
)
|
||||
|
||||
# Phase 4 (Robustness V1): append-only audit log for memory mutations.
|
||||
# Every create/update/promote/reject/supersede/invalidate/reinforce/expire/
|
||||
# auto_promote writes one row here. before/after are JSON snapshots of the
|
||||
# relevant fields. actor lets us distinguish auto-triage vs human-triage vs
|
||||
# api vs cron. This is the "how did this memory get to its current state"
|
||||
# trail — essential once the brain starts auto-organizing itself.
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS memory_audit (
|
||||
id TEXT PRIMARY KEY,
|
||||
memory_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
actor TEXT DEFAULT 'api',
|
||||
before_json TEXT DEFAULT '{}',
|
||||
after_json TEXT DEFAULT '{}',
|
||||
note TEXT DEFAULT '',
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memory_audit_memory ON memory_audit(memory_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memory_audit_timestamp ON memory_audit(timestamp)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memory_audit_action ON memory_audit(action)")
|
||||
|
||||
# Phase 5 (Engineering V1): entity_kind discriminator lets one audit
|
||||
# table serve both memories AND entities. Default "memory" keeps existing
|
||||
# rows correct; entity mutations write entity_kind="entity".
|
||||
if not _column_exists(conn, "memory_audit", "entity_kind"):
|
||||
conn.execute("ALTER TABLE memory_audit ADD COLUMN entity_kind TEXT DEFAULT 'memory'")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_memory_audit_entity_kind ON memory_audit(entity_kind)"
|
||||
)
|
||||
|
||||
# Phase 5: conflicts + conflict_members tables per conflict-model.md.
|
||||
# A conflict is "two or more active rows claiming the same slot with
|
||||
# incompatible values". slot_kind + slot_key identify the logical slot
|
||||
# (e.g., "component.material" for some component id). Members point
|
||||
# back to the conflicting rows (memory or entity) with layer trust so
|
||||
# resolution can pick the highest-trust winner.
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS conflicts (
|
||||
id TEXT PRIMARY KEY,
|
||||
slot_kind TEXT NOT NULL,
|
||||
slot_key TEXT NOT NULL,
|
||||
project TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'open',
|
||||
resolution TEXT DEFAULT '',
|
||||
resolved_at DATETIME,
|
||||
detected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
note TEXT DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS conflict_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
conflict_id TEXT NOT NULL REFERENCES conflicts(id) ON DELETE CASCADE,
|
||||
member_kind TEXT NOT NULL,
|
||||
member_id TEXT NOT NULL,
|
||||
member_layer_trust INTEGER DEFAULT 0,
|
||||
value_snapshot TEXT DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_conflicts_status ON conflicts(status)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_conflicts_project ON conflicts(project)")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_conflicts_slot ON conflicts(slot_kind, slot_key)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_conflict_members_conflict ON conflict_members(conflict_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_conflict_members_member ON conflict_members(member_kind, member_id)"
|
||||
)
|
||||
|
||||
# Phase 9 Commit A: capture loop columns on the interactions table.
|
||||
# The original schema only carried prompt + project_id + a context_pack
|
||||
# JSON blob. To make interactions a real audit trail of what AtoCore fed
|
||||
@@ -142,6 +251,69 @@ def _apply_migrations(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_interactions_created_at ON interactions(created_at)"
|
||||
)
|
||||
|
||||
# Phase 7A (Memory Consolidation — "sleep cycle"): merge candidates.
|
||||
# When the dedup detector finds a cluster of semantically similar active
|
||||
# memories within the same (project, memory_type) bucket, it drafts a
|
||||
# unified content via LLM and writes a proposal here. The triage UI
|
||||
# surfaces these for human approval. On approve, source memories become
|
||||
# status=superseded and a new merged memory is created.
|
||||
# memory_ids is a JSON array (length >= 2) of the source memory ids.
|
||||
# proposed_* hold the LLM's draft; a human can edit before approve.
|
||||
# result_memory_id is filled on approve with the new merged memory's id.
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS memory_merge_candidates (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT DEFAULT 'pending',
|
||||
memory_ids TEXT NOT NULL,
|
||||
similarity REAL,
|
||||
proposed_content TEXT,
|
||||
proposed_memory_type TEXT,
|
||||
proposed_project TEXT,
|
||||
proposed_tags TEXT DEFAULT '[]',
|
||||
proposed_confidence REAL,
|
||||
reason TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at DATETIME,
|
||||
resolved_by TEXT,
|
||||
result_memory_id TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_mmc_status ON memory_merge_candidates(status)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_mmc_created_at ON memory_merge_candidates(created_at)"
|
||||
)
|
||||
|
||||
# Phase 7C (Memory Consolidation — tag canonicalization): alias → canonical
|
||||
# map for domain_tags. A weekly LLM pass proposes rows here; high-confidence
|
||||
# ones auto-apply (rewrite domain_tags across all memories), low-confidence
|
||||
# ones stay pending for human approval. Immutable history: resolved rows
|
||||
# keep status=approved/rejected; the same alias can re-appear with a new
|
||||
# id if the tag reaches a different canonical later.
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tag_aliases (
|
||||
id TEXT PRIMARY KEY,
|
||||
alias TEXT NOT NULL,
|
||||
canonical TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
confidence REAL DEFAULT 0.0,
|
||||
alias_count INTEGER DEFAULT 0,
|
||||
canonical_count INTEGER DEFAULT 0,
|
||||
reason TEXT DEFAULT '',
|
||||
applied_to_memories INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at DATETIME,
|
||||
resolved_by TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tag_aliases_status ON tag_aliases(status)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tag_aliases_alias ON tag_aliases(alias)")
|
||||
|
||||
|
||||
def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
|
||||
170
src/atocore/observability/alerts.py
Normal file
170
src/atocore/observability/alerts.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Alert emission framework (Phase 4 Robustness V1).
|
||||
|
||||
One-stop helper to raise operational alerts from any AtoCore code
|
||||
path. An alert is a structured message about something the operator
|
||||
should see — harness regression, queue pileup, integrity drift,
|
||||
pipeline skipped, etc.
|
||||
|
||||
Emission fans out to multiple sinks so a single call touches every
|
||||
observability channel:
|
||||
|
||||
1. structlog logger (always)
|
||||
2. Append to ``$ATOCORE_ALERT_LOG`` (default ~/atocore-logs/alerts.log)
|
||||
3. Write the last alert of each severity to AtoCore project state
|
||||
(atocore/alert/last_{severity}) so the dashboard can surface it
|
||||
4. POST to ``$ATOCORE_ALERT_WEBHOOK`` if set (Discord/Slack/generic)
|
||||
|
||||
All sinks are fail-open — if one fails the others still fire.
|
||||
|
||||
Severity levels (inspired by syslog but simpler):
|
||||
- ``info`` operational event worth noting
|
||||
- ``warning`` degraded state, service still works
|
||||
- ``critical`` something is broken and needs attention
|
||||
|
||||
Environment variables:
|
||||
ATOCORE_ALERT_LOG override the alerts log file path
|
||||
ATOCORE_ALERT_WEBHOOK POST JSON alerts here (Discord webhook, etc.)
|
||||
ATOCORE_BASE_URL AtoCore API for project-state write (default localhost:8100)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from atocore.observability.logger import get_logger
|
||||
|
||||
log = get_logger("alerts")
|
||||
|
||||
SEVERITIES = {"info", "warning", "critical"}
|
||||
|
||||
|
||||
def _default_alert_log() -> Path:
|
||||
explicit = os.environ.get("ATOCORE_ALERT_LOG")
|
||||
if explicit:
|
||||
return Path(explicit)
|
||||
return Path.home() / "atocore-logs" / "alerts.log"
|
||||
|
||||
|
||||
def _append_log(severity: str, title: str, message: str, context: dict | None) -> None:
|
||||
path = _default_alert_log()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
line = f"[{ts}] [{severity.upper()}] {title}: {message}"
|
||||
if context:
|
||||
line += f" {json.dumps(context, ensure_ascii=True)[:500]}"
|
||||
line += "\n"
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
except Exception as e:
|
||||
log.warning("alert_log_write_failed", error=str(e))
|
||||
|
||||
|
||||
def _write_state(severity: str, title: str, message: str, ts: str) -> None:
|
||||
"""Record the most-recent alert per severity into project_state.
|
||||
|
||||
Uses the internal ``set_state`` helper directly so we work even
|
||||
when the HTTP API isn't available (e.g. called from cron scripts
|
||||
that import atocore as a library).
|
||||
"""
|
||||
try:
|
||||
from atocore.context.project_state import set_state
|
||||
|
||||
set_state(
|
||||
project_name="atocore",
|
||||
category="alert",
|
||||
key=f"last_{severity}",
|
||||
value=json.dumps({"title": title, "message": message[:400], "timestamp": ts}),
|
||||
source="alert framework",
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning("alert_state_write_failed", error=str(e))
|
||||
|
||||
|
||||
def _post_webhook(severity: str, title: str, message: str, context: dict | None, ts: str) -> None:
|
||||
url = os.environ.get("ATOCORE_ALERT_WEBHOOK")
|
||||
if not url:
|
||||
return
|
||||
|
||||
# Auto-detect Discord webhook shape for nicer formatting
|
||||
if "discord.com/api/webhooks" in url or "discordapp.com/api/webhooks" in url:
|
||||
emoji = {"info": ":information_source:", "warning": ":warning:", "critical": ":rotating_light:"}.get(severity, "")
|
||||
body = {
|
||||
"content": f"{emoji} **AtoCore {severity}**: {title}",
|
||||
"embeds": [{
|
||||
"description": message[:1800],
|
||||
"timestamp": ts,
|
||||
"fields": [
|
||||
{"name": k, "value": str(v)[:200], "inline": True}
|
||||
for k, v in (context or {}).items()
|
||||
][:10],
|
||||
}],
|
||||
}
|
||||
else:
|
||||
body = {
|
||||
"severity": severity,
|
||||
"title": title,
|
||||
"message": message,
|
||||
"context": context or {},
|
||||
"timestamp": ts,
|
||||
}
|
||||
|
||||
def _fire():
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=8)
|
||||
except Exception as e:
|
||||
log.warning("alert_webhook_failed", error=str(e))
|
||||
|
||||
threading.Thread(target=_fire, daemon=True).start()
|
||||
|
||||
|
||||
def emit_alert(
|
||||
severity: str,
|
||||
title: str,
|
||||
message: str,
|
||||
context: dict | None = None,
|
||||
) -> None:
|
||||
"""Emit an alert to all configured sinks.
|
||||
|
||||
Fail-open: any single sink failure is logged but does not prevent
|
||||
other sinks from firing.
|
||||
"""
|
||||
severity = (severity or "info").lower()
|
||||
if severity not in SEVERITIES:
|
||||
severity = "info"
|
||||
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Sink 1: structlog — always
|
||||
logger_fn = {
|
||||
"info": log.info,
|
||||
"warning": log.warning,
|
||||
"critical": log.error,
|
||||
}[severity]
|
||||
logger_fn("alert", title=title, message=message[:500], **(context or {}))
|
||||
|
||||
# Sinks 2-4: fail-open, each wrapped
|
||||
try:
|
||||
_append_log(severity, title, message, context)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_write_state(severity, title, message, ts)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_post_webhook(severity, title, message, context, ts)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -103,12 +103,27 @@ def create_runtime_backup(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Automatic post-backup validation. Failures log a warning but do
|
||||
# not raise — the backup files are still on disk and may be useful.
|
||||
validation = validate_backup(stamp)
|
||||
validated = validation.get("valid", False)
|
||||
validation_errors = validation.get("errors", [])
|
||||
if not validated:
|
||||
log.warning(
|
||||
"post_backup_validation_failed",
|
||||
backup_root=str(backup_root),
|
||||
errors=validation_errors,
|
||||
)
|
||||
metadata["validated"] = validated
|
||||
metadata["validation_errors"] = validation_errors
|
||||
|
||||
log.info(
|
||||
"runtime_backup_created",
|
||||
backup_root=str(backup_root),
|
||||
db_snapshot=str(db_snapshot_path),
|
||||
chroma_included=include_chroma,
|
||||
chroma_bytes=chroma_bytes_copied,
|
||||
validated=validated,
|
||||
)
|
||||
return metadata
|
||||
|
||||
@@ -216,6 +231,286 @@ def validate_backup(stamp: str) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def restore_runtime_backup(
|
||||
stamp: str,
|
||||
*,
|
||||
include_chroma: bool | None = None,
|
||||
pre_restore_snapshot: bool = True,
|
||||
confirm_service_stopped: bool = False,
|
||||
) -> dict:
|
||||
"""Restore a previously captured runtime backup.
|
||||
|
||||
CRITICAL: the AtoCore service MUST be stopped before calling this.
|
||||
Overwriting a live SQLite database corrupts state and can break
|
||||
the running container's open connections. The caller must pass
|
||||
``confirm_service_stopped=True`` as an explicit acknowledgment —
|
||||
otherwise this function refuses to run.
|
||||
|
||||
The restore procedure:
|
||||
|
||||
1. Validate the backup via ``validate_backup``; refuse on any error.
|
||||
2. (default) Create a pre-restore safety snapshot of the CURRENT
|
||||
state so the restore itself is reversible. The snapshot stamp
|
||||
is returned in the result for the operator to record.
|
||||
3. Remove stale SQLite WAL/SHM sidecar files next to the target db
|
||||
before copying — the snapshot is a self-contained main-file
|
||||
image from ``conn.backup()``, and leftover WAL/SHM from the old
|
||||
live db would desync against the restored main file.
|
||||
4. Copy the snapshot db over the target db path.
|
||||
5. Restore the project registry file if the snapshot captured one.
|
||||
6. Restore the Chroma directory if ``include_chroma`` resolves to
|
||||
true. When ``include_chroma is None`` the function defers to
|
||||
whether the snapshot captured Chroma (the common case).
|
||||
7. Run ``PRAGMA integrity_check`` on the restored db and report
|
||||
the result.
|
||||
|
||||
Returns a dict describing what was restored. On refused restore
|
||||
(service still running, validation failed) raises ``RuntimeError``.
|
||||
"""
|
||||
if not confirm_service_stopped:
|
||||
raise RuntimeError(
|
||||
"restore_runtime_backup refuses to run without "
|
||||
"confirm_service_stopped=True — stop the AtoCore container "
|
||||
"first (e.g. `docker compose down` from deploy/dalidou) "
|
||||
"before calling this function"
|
||||
)
|
||||
|
||||
validation = validate_backup(stamp)
|
||||
if not validation.get("valid"):
|
||||
raise RuntimeError(
|
||||
f"backup {stamp} failed validation: {validation.get('errors')}"
|
||||
)
|
||||
metadata = validation.get("metadata") or {}
|
||||
|
||||
pre_snapshot_stamp: str | None = None
|
||||
if pre_restore_snapshot:
|
||||
pre = create_runtime_backup(include_chroma=False)
|
||||
pre_snapshot_stamp = Path(pre["backup_root"]).name
|
||||
|
||||
target_db = _config.settings.db_path
|
||||
source_db = Path(metadata.get("db_snapshot_path", ""))
|
||||
if not source_db.exists():
|
||||
raise RuntimeError(
|
||||
f"db snapshot not found at {source_db} — backup "
|
||||
f"metadata may be stale"
|
||||
)
|
||||
|
||||
# Force sqlite to flush any lingering WAL into the main file and
|
||||
# release OS-level file handles on -wal/-shm before we swap the
|
||||
# main file. Passing through conn.backup() in the pre-restore
|
||||
# snapshot can leave sidecars momentarily locked on Windows;
|
||||
# an explicit checkpoint(TRUNCATE) is the reliable way to flush
|
||||
# and release. Best-effort: if the target db can't be opened
|
||||
# (missing, corrupt), fall through and trust the copy step.
|
||||
if target_db.exists():
|
||||
try:
|
||||
with sqlite3.connect(str(target_db)) as checkpoint_conn:
|
||||
checkpoint_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except sqlite3.DatabaseError as exc:
|
||||
log.warning(
|
||||
"restore_pre_checkpoint_failed",
|
||||
target_db=str(target_db),
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
# Remove stale WAL/SHM sidecars from the old live db so SQLite
|
||||
# can't read inconsistent state on next open. Tolerant to
|
||||
# Windows file-lock races — the subsequent copy replaces the
|
||||
# main file anyway, and the integrity check afterward is the
|
||||
# actual correctness signal.
|
||||
wal_path = target_db.with_name(target_db.name + "-wal")
|
||||
shm_path = target_db.with_name(target_db.name + "-shm")
|
||||
for stale in (wal_path, shm_path):
|
||||
if stale.exists():
|
||||
try:
|
||||
stale.unlink()
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"restore_sidecar_unlink_failed",
|
||||
path=str(stale),
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
target_db.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source_db, target_db)
|
||||
|
||||
registry_restored = False
|
||||
registry_snapshot_path = metadata.get("registry_snapshot_path", "")
|
||||
if registry_snapshot_path:
|
||||
src_reg = Path(registry_snapshot_path)
|
||||
if src_reg.exists():
|
||||
dst_reg = _config.settings.resolved_project_registry_path
|
||||
dst_reg.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src_reg, dst_reg)
|
||||
registry_restored = True
|
||||
|
||||
chroma_snapshot_path = metadata.get("chroma_snapshot_path", "")
|
||||
if include_chroma is None:
|
||||
include_chroma = bool(chroma_snapshot_path)
|
||||
chroma_restored = False
|
||||
if include_chroma and chroma_snapshot_path:
|
||||
src_chroma = Path(chroma_snapshot_path)
|
||||
if src_chroma.exists() and src_chroma.is_dir():
|
||||
dst_chroma = _config.settings.chroma_path
|
||||
# Do NOT rmtree the destination itself: in a Dockerized
|
||||
# deployment the chroma dir is a bind-mounted volume, and
|
||||
# unlinking a mount point raises
|
||||
# OSError [Errno 16] Device or resource busy.
|
||||
# Instead, clear the directory's CONTENTS and copytree into
|
||||
# it with dirs_exist_ok=True. This is equivalent to an
|
||||
# rmtree+copytree for restore purposes but stays inside the
|
||||
# mount boundary. Discovered during the first real restore
|
||||
# drill on Dalidou (2026-04-09).
|
||||
dst_chroma.mkdir(parents=True, exist_ok=True)
|
||||
for item in dst_chroma.iterdir():
|
||||
if item.is_dir() and not item.is_symlink():
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
item.unlink()
|
||||
shutil.copytree(src_chroma, dst_chroma, dirs_exist_ok=True)
|
||||
chroma_restored = True
|
||||
|
||||
restored_integrity_ok = False
|
||||
integrity_error: str | None = None
|
||||
try:
|
||||
with sqlite3.connect(str(target_db)) as conn:
|
||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
||||
restored_integrity_ok = bool(row and row[0] == "ok")
|
||||
if not restored_integrity_ok:
|
||||
integrity_error = row[0] if row else "no_row"
|
||||
except sqlite3.DatabaseError as exc:
|
||||
integrity_error = f"db_open_failed: {exc}"
|
||||
|
||||
result: dict = {
|
||||
"stamp": stamp,
|
||||
"pre_restore_snapshot": pre_snapshot_stamp,
|
||||
"target_db": str(target_db),
|
||||
"db_restored": True,
|
||||
"registry_restored": registry_restored,
|
||||
"chroma_restored": chroma_restored,
|
||||
"restored_integrity_ok": restored_integrity_ok,
|
||||
}
|
||||
if integrity_error:
|
||||
result["integrity_error"] = integrity_error
|
||||
|
||||
log.info(
|
||||
"runtime_backup_restored",
|
||||
stamp=stamp,
|
||||
pre_restore_snapshot=pre_snapshot_stamp,
|
||||
registry_restored=registry_restored,
|
||||
chroma_restored=chroma_restored,
|
||||
integrity_ok=restored_integrity_ok,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def cleanup_old_backups(*, confirm: bool = False) -> dict:
|
||||
"""Apply retention policy and remove old snapshots.
|
||||
|
||||
Retention keeps:
|
||||
- Last 7 daily snapshots (most recent per calendar day)
|
||||
- Last 4 weekly snapshots (most recent on each Sunday)
|
||||
- Last 6 monthly snapshots (most recent on the 1st of each month)
|
||||
|
||||
All other snapshots are candidates for deletion. Runs as dry-run by
|
||||
default; pass ``confirm=True`` to actually delete.
|
||||
|
||||
Returns a dict with kept/deleted counts and any errors.
|
||||
"""
|
||||
snapshots_root = _config.settings.resolved_backup_dir / "snapshots"
|
||||
if not snapshots_root.exists() or not snapshots_root.is_dir():
|
||||
return {"kept": 0, "deleted": 0, "would_delete": 0, "dry_run": not confirm, "errors": []}
|
||||
|
||||
# Parse all stamp directories into (datetime, dir_path) pairs.
|
||||
stamps: list[tuple[datetime, Path]] = []
|
||||
unparseable: list[str] = []
|
||||
for entry in sorted(snapshots_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
try:
|
||||
dt = datetime.strptime(entry.name, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
|
||||
stamps.append((dt, entry))
|
||||
except ValueError:
|
||||
unparseable.append(entry.name)
|
||||
|
||||
if not stamps:
|
||||
return {
|
||||
"kept": 0, "deleted": 0, "would_delete": 0,
|
||||
"dry_run": not confirm, "errors": [],
|
||||
"unparseable": unparseable,
|
||||
}
|
||||
|
||||
# Sort newest first so "most recent per bucket" is a simple first-seen.
|
||||
stamps.sort(key=lambda t: t[0], reverse=True)
|
||||
|
||||
keep_set: set[Path] = set()
|
||||
|
||||
# Last 7 daily: most recent snapshot per calendar day.
|
||||
seen_days: set[str] = set()
|
||||
for dt, path in stamps:
|
||||
day_key = dt.strftime("%Y-%m-%d")
|
||||
if day_key not in seen_days:
|
||||
seen_days.add(day_key)
|
||||
keep_set.add(path)
|
||||
if len(seen_days) >= 7:
|
||||
break
|
||||
|
||||
# Last 4 weekly: most recent snapshot that falls on a Sunday.
|
||||
seen_weeks: set[str] = set()
|
||||
for dt, path in stamps:
|
||||
if dt.weekday() == 6: # Sunday
|
||||
week_key = dt.strftime("%Y-W%W")
|
||||
if week_key not in seen_weeks:
|
||||
seen_weeks.add(week_key)
|
||||
keep_set.add(path)
|
||||
if len(seen_weeks) >= 4:
|
||||
break
|
||||
|
||||
# Last 6 monthly: most recent snapshot on the 1st of a month.
|
||||
seen_months: set[str] = set()
|
||||
for dt, path in stamps:
|
||||
if dt.day == 1:
|
||||
month_key = dt.strftime("%Y-%m")
|
||||
if month_key not in seen_months:
|
||||
seen_months.add(month_key)
|
||||
keep_set.add(path)
|
||||
if len(seen_months) >= 6:
|
||||
break
|
||||
|
||||
to_delete = [path for _, path in stamps if path not in keep_set]
|
||||
|
||||
errors: list[str] = []
|
||||
deleted_count = 0
|
||||
if confirm:
|
||||
for path in to_delete:
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
deleted_count += 1
|
||||
except OSError as exc:
|
||||
errors.append(f"{path.name}: {exc}")
|
||||
|
||||
result: dict = {
|
||||
"kept": len(keep_set),
|
||||
"dry_run": not confirm,
|
||||
"errors": errors,
|
||||
}
|
||||
if confirm:
|
||||
result["deleted"] = deleted_count
|
||||
else:
|
||||
result["would_delete"] = len(to_delete)
|
||||
if unparseable:
|
||||
result["unparseable"] = unparseable
|
||||
|
||||
log.info(
|
||||
"cleanup_old_backups",
|
||||
kept=len(keep_set),
|
||||
deleted=deleted_count if confirm else 0,
|
||||
would_delete=len(to_delete) if not confirm else 0,
|
||||
dry_run=not confirm,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _backup_sqlite_db(source_path: Path, dest_path: Path) -> None:
|
||||
source_conn = sqlite3.connect(str(source_path))
|
||||
dest_conn = sqlite3.connect(str(dest_path))
|
||||
@@ -242,7 +537,98 @@ def _copy_directory_tree(source: Path, dest: Path) -> tuple[int, int]:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
result = create_runtime_backup()
|
||||
"""CLI entry point for the backup module.
|
||||
|
||||
Supports four subcommands:
|
||||
|
||||
- ``create`` run ``create_runtime_backup`` (default if none given)
|
||||
- ``list`` list all runtime backup snapshots
|
||||
- ``validate`` validate a specific snapshot by stamp
|
||||
- ``restore`` restore a specific snapshot by stamp
|
||||
|
||||
The restore subcommand is the one used by the backup/restore drill
|
||||
and MUST be run only when the AtoCore service is stopped. It takes
|
||||
``--confirm-service-stopped`` as an explicit acknowledgment.
|
||||
"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m atocore.ops.backup",
|
||||
description="AtoCore runtime backup create/list/validate/restore",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
p_create = sub.add_parser("create", help="create a new runtime backup")
|
||||
p_create.add_argument(
|
||||
"--chroma",
|
||||
action="store_true",
|
||||
help="also snapshot the Chroma vector store (cold copy)",
|
||||
)
|
||||
|
||||
sub.add_parser("list", help="list runtime backup snapshots")
|
||||
|
||||
p_validate = sub.add_parser("validate", help="validate a snapshot by stamp")
|
||||
p_validate.add_argument("stamp", help="snapshot stamp (e.g. 20260409T010203Z)")
|
||||
|
||||
p_cleanup = sub.add_parser("cleanup", help="remove old snapshots per retention policy")
|
||||
p_cleanup.add_argument(
|
||||
"--confirm",
|
||||
action="store_true",
|
||||
help="actually delete (default is dry-run)",
|
||||
)
|
||||
|
||||
p_restore = sub.add_parser(
|
||||
"restore",
|
||||
help="restore a snapshot by stamp (service must be stopped)",
|
||||
)
|
||||
p_restore.add_argument("stamp", help="snapshot stamp to restore")
|
||||
p_restore.add_argument(
|
||||
"--confirm-service-stopped",
|
||||
action="store_true",
|
||||
help="explicit acknowledgment that the AtoCore container is stopped",
|
||||
)
|
||||
p_restore.add_argument(
|
||||
"--no-pre-snapshot",
|
||||
action="store_true",
|
||||
help="skip the pre-restore safety snapshot of current state",
|
||||
)
|
||||
chroma_group = p_restore.add_mutually_exclusive_group()
|
||||
chroma_group.add_argument(
|
||||
"--chroma",
|
||||
dest="include_chroma",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="force-restore the Chroma snapshot",
|
||||
)
|
||||
chroma_group.add_argument(
|
||||
"--no-chroma",
|
||||
dest="include_chroma",
|
||||
action="store_false",
|
||||
help="skip the Chroma snapshot even if it was captured",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
command = args.command or "create"
|
||||
|
||||
if command == "create":
|
||||
include_chroma = getattr(args, "chroma", False)
|
||||
result = create_runtime_backup(include_chroma=include_chroma)
|
||||
elif command == "list":
|
||||
result = {"backups": list_runtime_backups()}
|
||||
elif command == "validate":
|
||||
result = validate_backup(args.stamp)
|
||||
elif command == "cleanup":
|
||||
result = cleanup_old_backups(confirm=getattr(args, "confirm", False))
|
||||
elif command == "restore":
|
||||
result = restore_runtime_backup(
|
||||
args.stamp,
|
||||
include_chroma=args.include_chroma,
|
||||
pre_restore_snapshot=not args.no_pre_snapshot,
|
||||
confirm_service_stopped=args.confirm_service_stopped,
|
||||
)
|
||||
else: # pragma: no cover — argparse guards this
|
||||
parser.error(f"unknown command: {command}")
|
||||
|
||||
print(json.dumps(result, indent=2, ensure_ascii=True))
|
||||
|
||||
|
||||
|
||||
@@ -254,6 +254,30 @@ def get_registered_project(project_name: str) -> RegisteredProject | None:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_project_name(name: str | None) -> str:
|
||||
"""Canonicalize a project name through the registry.
|
||||
|
||||
Returns the canonical ``project_id`` if the input matches any
|
||||
registered project's id or alias. Returns the input unchanged
|
||||
when it's empty or not in the registry — the second case keeps
|
||||
backwards compatibility with hand-curated state, memories, and
|
||||
interactions that predate the registry, or for projects that
|
||||
are intentionally not registered.
|
||||
|
||||
This helper is the single canonicalization boundary for project
|
||||
names across the trust hierarchy. Every read/write that takes a
|
||||
project name should pass it through ``resolve_project_name``
|
||||
before storing or querying. The contract is documented in
|
||||
``docs/architecture/representation-authority.md``.
|
||||
"""
|
||||
if not name:
|
||||
return name or ""
|
||||
project = get_registered_project(name)
|
||||
if project is not None:
|
||||
return project.project_id
|
||||
return name
|
||||
|
||||
|
||||
def refresh_registered_project(project_name: str, purge_deleted: bool = False) -> dict:
|
||||
"""Ingest all configured source roots for a registered project.
|
||||
|
||||
|
||||
234
t420-openclaw/AGENTS.md
Normal file
234
t420-openclaw/AGENTS.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# AGENTS.md - Your Workspace
|
||||
|
||||
This folder is home. Treat it that way.
|
||||
|
||||
## First Run
|
||||
|
||||
If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out who you are, then delete it. You won't need it again.
|
||||
|
||||
## Every Session
|
||||
|
||||
Before doing anything else:
|
||||
1. Read `SOUL.md` — this is who you are
|
||||
2. Read `USER.md` — this is who you're helping
|
||||
3. Read `MODEL-ROUTING.md` — follow the auto-routing policy for model selection
|
||||
4. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context
|
||||
5. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md`
|
||||
|
||||
Don't ask permission. Just do it.
|
||||
|
||||
## Memory
|
||||
|
||||
You wake up fresh each session. These files are your continuity:
|
||||
- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed) — raw logs of what happened
|
||||
- **Long-term:** `MEMORY.md` — your curated memories, like a human's long-term memory
|
||||
|
||||
Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them.
|
||||
|
||||
### 🧠 MEMORY.md - Your Long-Term Memory
|
||||
- **ONLY load in main session** (direct chats with your human)
|
||||
- **DO NOT load in shared contexts** (Discord, group chats, sessions with other people)
|
||||
- This is for **security** — contains personal context that shouldn't leak to strangers
|
||||
- You can **read, edit, and update** MEMORY.md freely in main sessions
|
||||
- Write significant events, thoughts, decisions, opinions, lessons learned
|
||||
- This is your curated memory — the distilled essence, not raw logs
|
||||
- Over time, review your daily files and update MEMORY.md with what's worth keeping
|
||||
|
||||
### 📝 Write It Down - No "Mental Notes"!
|
||||
- **Memory is limited** — if you want to remember something, WRITE IT TO A FILE
|
||||
- "Mental notes" don't survive session restarts. Files do.
|
||||
- When someone says "remember this" → update `memory/YYYY-MM-DD.md` or relevant file
|
||||
- When you learn a lesson → update AGENTS.md, TOOLS.md, or the relevant skill
|
||||
- When you make a mistake → document it so future-you doesn't repeat it
|
||||
- **Text > Brain** 📝
|
||||
|
||||
## Safety
|
||||
|
||||
- Don't exfiltrate private data. Ever.
|
||||
- Don't run destructive commands without asking.
|
||||
- `trash` > `rm` (recoverable beats gone forever)
|
||||
- When in doubt, ask.
|
||||
|
||||
## External vs Internal
|
||||
|
||||
**Safe to do freely:**
|
||||
- Read files, explore, organize, learn
|
||||
- Search the web, check calendars
|
||||
- Work within this workspace
|
||||
|
||||
**Ask first:**
|
||||
- Sending emails, tweets, public posts
|
||||
- Anything that leaves the machine
|
||||
- Anything you're uncertain about
|
||||
|
||||
## Group Chats
|
||||
|
||||
You have access to your human's stuff. That doesn't mean you *share* their stuff. In groups, you're a participant — not their voice, not their proxy. Think before you speak.
|
||||
|
||||
### 💬 Know When to Speak!
|
||||
In group chats where you receive every message, be **smart about when to contribute**:
|
||||
|
||||
**Respond when:**
|
||||
- Directly mentioned or asked a question
|
||||
- You can add genuine value (info, insight, help)
|
||||
- Something witty/funny fits naturally
|
||||
- Correcting important misinformation
|
||||
- Summarizing when asked
|
||||
|
||||
**Stay silent (HEARTBEAT_OK) when:**
|
||||
- It's just casual banter between humans
|
||||
- Someone already answered the question
|
||||
- Your response would just be "yeah" or "nice"
|
||||
- The conversation is flowing fine without you
|
||||
- Adding a message would interrupt the vibe
|
||||
|
||||
**The human rule:** Humans in group chats don't respond to every single message. Neither should you. Quality > quantity. If you wouldn't send it in a real group chat with friends, don't send it.
|
||||
|
||||
**Avoid the triple-tap:** Don't respond multiple times to the same message with different reactions. One thoughtful response beats three fragments.
|
||||
|
||||
Participate, don't dominate.
|
||||
|
||||
### 😊 React Like a Human!
|
||||
On platforms that support reactions (Discord, Slack), use emoji reactions naturally:
|
||||
|
||||
**React when:**
|
||||
- You appreciate something but don't need to reply (👍, ❤️, 🙌)
|
||||
- Something made you laugh (😂, 💀)
|
||||
- You find it interesting or thought-provoking (🤔, 💡)
|
||||
- You want to acknowledge without interrupting the flow
|
||||
- It's a simple yes/no or approval situation (✅, 👀)
|
||||
|
||||
**Why it matters:**
|
||||
Reactions are lightweight social signals. Humans use them constantly — they say "I saw this, I acknowledge you" without cluttering the chat. You should too.
|
||||
|
||||
**Don't overdo it:** One reaction per message max. Pick the one that fits best.
|
||||
|
||||
## Tools
|
||||
|
||||
When a task is contextual and project-dependent, use the `atocore-context` skill to query Dalidou-hosted AtoCore for trusted project state, retrieval, context-building, registered project refresh, or project registration discovery when that will improve accuracy. Treat AtoCore as additive and fail-open; do not replace OpenClaw's own memory with it. Prefer `projects` and `refresh-project <id>` when a known project needs a clean source refresh, and use `project-template` when proposing a new project registration, and `propose-project ...` when you want a normalized preview before editing the registry manually.
|
||||
|
||||
### Organic AtoCore Routing
|
||||
|
||||
For normal project knowledge questions, use AtoCore by default without waiting for the human to ask for the helper explicitly.
|
||||
|
||||
Use AtoCore first when the prompt:
|
||||
- mentions a registered project id or alias
|
||||
- asks about architecture, constraints, status, requirements, vendors, planning, prior decisions, or current project truth
|
||||
- would benefit from cross-source context instead of only the local repo
|
||||
|
||||
Preferred flow:
|
||||
1. `auto-context "<prompt>" 3000` for most project knowledge questions
|
||||
2. `project-state <project>` when the user is clearly asking for trusted current truth
|
||||
3. `audit-query "<prompt>" 5 [project]` when broad prompts drift, archive/history noise appears, or retrieval quality is being evaluated
|
||||
4. `refresh-project <id>` before answering if the user explicitly asked to refresh or ingest project changes
|
||||
|
||||
For AtoCore improvement work, prefer this sequence:
|
||||
1. retrieval-quality pass
|
||||
2. Wave 2 trusted-operational ingestion
|
||||
3. AtoDrive clarification
|
||||
4. restore and ops validation
|
||||
|
||||
Wave 2 trusted-operational truth should prioritize:
|
||||
- current status
|
||||
- current decisions
|
||||
- requirements baseline
|
||||
- milestone plan
|
||||
- next actions
|
||||
|
||||
Do not ingest the whole PKM vault before the trusted-operational layer is in good shape. Treat AtoDrive as curated operational truth, not a generic dump.
|
||||
|
||||
Do not force AtoCore for purely local coding actions like fixing a function, editing one file, or running tests, unless broader project context is likely to matter.
|
||||
|
||||
If `auto-context` returns `no_project_match` or AtoCore is unavailable, continue normally with OpenClaw's own tools and memory.
|
||||
|
||||
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
|
||||
|
||||
**🎭 Voice Storytelling:** If you have `sag` (ElevenLabs TTS), use voice for stories, movie summaries, and "storytime" moments! Way more engaging than walls of text. Surprise people with funny voices.
|
||||
|
||||
**📝 Platform Formatting:**
|
||||
- **Discord/WhatsApp:** No markdown tables! Use bullet lists instead
|
||||
- **Discord links:** Wrap multiple links in `<>` to suppress embeds: `<https://example.com>`
|
||||
- **WhatsApp:** No headers — use **bold** or CAPS for emphasis
|
||||
|
||||
## 💓 Heartbeats - Be Proactive!
|
||||
|
||||
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
|
||||
|
||||
Default heartbeat prompt:
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
|
||||
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
|
||||
|
||||
### Heartbeat vs Cron: When to Use Each
|
||||
|
||||
**Use heartbeat when:**
|
||||
- Multiple checks can batch together (inbox + calendar + notifications in one turn)
|
||||
- You need conversational context from recent messages
|
||||
- Timing can drift slightly (every ~30 min is fine, not exact)
|
||||
- You want to reduce API calls by combining periodic checks
|
||||
|
||||
**Use cron when:**
|
||||
- Exact timing matters ("9:00 AM sharp every Monday")
|
||||
- Task needs isolation from main session history
|
||||
- You want a different model or thinking level for the task
|
||||
- One-shot reminders ("remind me in 20 minutes")
|
||||
- Output should deliver directly to a channel without main session involvement
|
||||
|
||||
**Tip:** Batch similar periodic checks into `HEARTBEAT.md` instead of creating multiple cron jobs. Use cron for precise schedules and standalone tasks.
|
||||
|
||||
**Things to check (rotate through these, 2-4 times per day):**
|
||||
- **Emails** - Any urgent unread messages?
|
||||
- **Calendar** - Upcoming events in next 24-48h?
|
||||
- **Mentions** - Twitter/social notifications?
|
||||
- **Weather** - Relevant if your human might go out?
|
||||
|
||||
**Track your checks** in `memory/heartbeat-state.json`:
|
||||
```json
|
||||
{
|
||||
"lastChecks": {
|
||||
"email": 1703275200,
|
||||
"calendar": 1703260800,
|
||||
"weather": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to reach out:**
|
||||
- Important email arrived
|
||||
- Calendar event coming up (<2h)
|
||||
- Something interesting you found
|
||||
- It's been >8h since you said anything
|
||||
|
||||
**When to stay quiet (HEARTBEAT_OK):**
|
||||
- Late night (23:00-08:00) unless urgent
|
||||
- Human is clearly busy
|
||||
- Nothing new since last check
|
||||
- You just checked <30 minutes ago
|
||||
|
||||
**Proactive work you can do without asking:**
|
||||
- Read and organize memory files
|
||||
- Check on projects (git status, etc.)
|
||||
- Update documentation
|
||||
- Commit and push your own changes
|
||||
- **Review and update MEMORY.md** (see below)
|
||||
|
||||
### 🔄 Memory Maintenance (During Heartbeats)
|
||||
Periodically (every few days), use a heartbeat to:
|
||||
1. Read through recent `memory/YYYY-MM-DD.md` files
|
||||
2. Identify significant events, lessons, or insights worth keeping long-term
|
||||
3. Update `MEMORY.md` with distilled learnings
|
||||
4. Remove outdated info from MEMORY.md that's no longer relevant
|
||||
|
||||
Think of it like a human reviewing their journal and updating their mental model. Daily files are raw notes; MEMORY.md is curated wisdom.
|
||||
|
||||
The goal: Be helpful without being annoying. Check in a few times a day, do useful background work, but respect quiet time.
|
||||
|
||||
## Make It Yours
|
||||
|
||||
This is a starting point. Add your own conventions, style, and rules as you figure out what works.
|
||||
|
||||
## Orchestration Completion Protocol
|
||||
After any orchestration chain completes (research → review → condensation):
|
||||
1. Secretary MUST be the final agent tasked
|
||||
2. Secretary produces the condensation file AND posts a distillate to Discord #reports
|
||||
3. Manager should include in Secretary's task: "Post a distillate to Discord #reports summarizing this orchestration"
|
||||
133
t420-openclaw/ATOCORE-OPERATIONS.md
Normal file
133
t420-openclaw/ATOCORE-OPERATIONS.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# AtoCore Operations
|
||||
|
||||
This is the current operating playbook for making AtoCore more dependable and higher-signal.
|
||||
|
||||
## Order of Work
|
||||
|
||||
1. Retrieval-quality pass
|
||||
2. Wave 2 trusted-operational ingestion
|
||||
3. AtoDrive clarification
|
||||
4. Restore and ops validation
|
||||
|
||||
## 1. Retrieval-Quality Pass
|
||||
|
||||
Observed behavior from the live service:
|
||||
|
||||
- broad prompts like `gigabit` and `polisher` still surface archive/history noise
|
||||
- meaningful prompts like `mirror frame stiffness requirements and selected architecture` are much sharper
|
||||
- now that the corpus is large enough, ranking quality matters more than raw corpus presence
|
||||
|
||||
Use these commands first:
|
||||
|
||||
```bash
|
||||
python atocore.py audit-query "gigabit" 5
|
||||
python atocore.py audit-query "polisher" 5
|
||||
python atocore.py audit-query "mirror frame stiffness requirements and selected architecture" 5 p04-gigabit
|
||||
python atocore.py audit-query "interferometer error budget and vendor selection constraints" 5 p05-interferometer
|
||||
python atocore.py audit-query "polisher system map shared contracts and calibration workflow" 5 p06-polisher
|
||||
```
|
||||
|
||||
What to fix in the retrieval pass:
|
||||
|
||||
- reduce `_archive`, `pre-cleanup`, `pre-migration`, and `History` prominence
|
||||
- prefer current-status, decision, requirement, architecture-freeze, and milestone docs
|
||||
- prefer trusted project-state over freeform notes when both speak to current truth
|
||||
- keep broad prompts from matching stale or generic chunks too easily
|
||||
|
||||
Suggested acceptance bar:
|
||||
|
||||
- top 5 for active-project prompts contain at least one current-status or next-focus item
|
||||
- top 5 contain at least one decision or architecture-baseline item
|
||||
- top 5 contain at least one requirement or constraints item
|
||||
- broad single-word prompts no longer lead with archive/history chunks
|
||||
|
||||
## 2. Wave 2 Trusted-Operational Ingestion
|
||||
|
||||
Do not ingest the whole PKM vault next.
|
||||
|
||||
Wave 2 should ingest trusted operational truth for each active project:
|
||||
|
||||
- current status dashboard or status note
|
||||
- current decisions / decision log
|
||||
- requirements baseline
|
||||
- architecture freeze / current baseline
|
||||
- milestone plan
|
||||
- next actions / near-term focus
|
||||
|
||||
Recommended helper flow:
|
||||
|
||||
```bash
|
||||
python atocore.py project-state p04-gigabit
|
||||
python atocore.py project-state p05-interferometer
|
||||
python atocore.py project-state p06-polisher
|
||||
python atocore.py project-state-set p04-gigabit status next_focus "Continue curated support and frame-context buildout." "Wave 2 status dashboard" 1.0
|
||||
python atocore.py project-state-set p05-interferometer requirement key_constraints "Preserve current error-budget, thermal, and vendor-selection constraints as the working baseline." "Wave 2 requirements baseline" 1.0
|
||||
python atocore.py project-state-set p06-polisher decision system_boundary "The suite remains a three-layer chain with explicit planning, translation, and execution boundaries." "Wave 2 decision log" 1.0
|
||||
python atocore.py refresh-project p04-gigabit
|
||||
python atocore.py refresh-project p05-interferometer
|
||||
python atocore.py refresh-project p06-polisher
|
||||
```
|
||||
|
||||
Use project-state for the most authoritative "current truth" fields, then refresh the registered project roots after curated Wave 2 documents land.
|
||||
|
||||
## 3. AtoDrive Clarification
|
||||
|
||||
AtoDrive should become a trusted-operational source, not a generic corpus dump.
|
||||
|
||||
Good AtoDrive candidates:
|
||||
|
||||
- current dashboards
|
||||
- current baselines
|
||||
- approved architecture docs
|
||||
- decision logs
|
||||
- milestone and next-step views
|
||||
- operational source-of-truth files that humans actively maintain
|
||||
|
||||
Avoid as default AtoDrive ingest:
|
||||
|
||||
- large generic archives
|
||||
- duplicated exports
|
||||
- stale snapshots when a newer baseline exists
|
||||
- exploratory notes that are not designated current truth
|
||||
|
||||
Rule of thumb:
|
||||
|
||||
- if the file answers "what is true now?" it may belong in trusted-operational
|
||||
- if the file mostly answers "what did we think at some point?" it belongs in the broader corpus, not Wave 2
|
||||
|
||||
## 4. Restore and Ops Validation
|
||||
|
||||
Backups are not enough until restore has been tested.
|
||||
|
||||
Validate these explicitly:
|
||||
|
||||
- SQLite metadata restore
|
||||
- Chroma restore or rebuild
|
||||
- registry restore
|
||||
- source-root refresh after restore
|
||||
- health and stats consistency after recovery
|
||||
|
||||
Recommended restore drill:
|
||||
|
||||
1. Record current `health`, `stats`, and `projects` output.
|
||||
2. Restore SQLite metadata and project registry from backup.
|
||||
3. Decide whether Chroma is restored from backup or rebuilt from source.
|
||||
4. Run project refresh for active projects.
|
||||
5. Compare vector/doc counts and run retrieval audits again.
|
||||
|
||||
Commands to capture the before/after baseline:
|
||||
|
||||
```bash
|
||||
python atocore.py health
|
||||
python atocore.py stats
|
||||
python atocore.py projects
|
||||
python atocore.py audit-query "gigabit" 5
|
||||
python atocore.py audit-query "interferometer error budget and vendor selection constraints" 5 p05-interferometer
|
||||
```
|
||||
|
||||
Recovery policy decision still needed:
|
||||
|
||||
- prefer Chroma backup restore for fast recovery when backup integrity is trusted
|
||||
- prefer Chroma rebuild when backups are suspect, schema changed, or ranking behavior drifts unexpectedly
|
||||
|
||||
The important part is to choose one policy on purpose and validate it, not leave it implicit.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user