17 Commits

Author SHA1 Message Date
57b64523fb feat: OpenClaw state importer — one-way pull via SSH
scripts/import_openclaw_state.py reads the OpenClaw file continuity
layer from clawdbot (T420) via SSH and imports candidate memories
into AtoCore. Loose coupling: OpenClaw's internals don't need to
change, AtoCore pulls from stable markdown files.

Per codex's integration proposal (docs/openclaw-atocore-integration-proposal.md):

Classification:
- SOUL.md          -> identity candidate
- USER.md          -> identity candidate
- MODEL-ROUTING.md -> adaptation candidate (routing rules)
- MEMORY.md        -> memory candidate (long-term curated)
- memory/YYYY-MM-DD.md -> episodic candidate (daily logs, last 7 days)
- heartbeat-state.json -> skipped (ops metadata only, not canonical)

Delta detection: SHA-256 hash per file stored in project_state
under atocore/status/openclaw_import_hashes. Only changed files
re-import. Hashes persist across runs so no wasted work.

All imports land as status=candidate. Auto-triage filters. Nothing
auto-promotes — the importer is a signal producer, the pipeline
decides what graduates.

Discord: deferred per codex's proposal — no durable local store in
current OpenClaw snapshot. Revisit if OpenClaw exposes an export.

Wired into cron-backup.sh as Step 3a (before vault refresh +
extraction) so OpenClaw signals flow through the same pipeline.
Gated on ATOCORE_OPENCLAW_IMPORT=true (default true).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:39:27 -04:00
a13ea3b9d1 docs: propose OpenClaw one-way pull integration 2026-04-14 10:34:15 -04:00
3f23ca1bc6 feat: signal-aggressive extraction + auto vault refresh in nightly cron
Extraction prompt rewritten for signal-aggressive mode. The old prompt
rewarded silence ("durable insight only, empty is correct") which
caused quiet failures — real project signal (Schott quotes arriving,
stakeholder events, blockers) was dropped as "not architectural enough".

New prompt explicitly lists what to emit:
1. Project activity (mentions with context — quote received, blocker,
   action item)
2. Decisions and choices (architectural commitments, vendor selection)
3. Durable engineering insight (earned knowledge, generalizable)
4. Stakeholder and vendor events (emails sent, meetings scheduled)
5. Preferences and adaptations (how Antoine works)

Philosophy shift: "capture more signal, let triage filter noise"
replaces "extract only durable architectural facts". Auto-triage
already rejects noise well, so moving the filter downstream gives us
visibility into weak signals without polluting active memory.

Added 'episodic' to the candidate types list to support stakeholder
events with a timestamp feel.

LLM_EXTRACTOR_VERSION bumped to llm-0.4.0.

Also: cron-backup.sh now runs POST /ingest/sources before extraction
so new PKM files flow in automatically. Fail-open, non-blocking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:24:50 -04:00
c1f5b3bdee feat: Karpathy-inspired upgrades — contradiction, lint, synthesis
Three additive upgrades borrowed from Karpathy's LLM Wiki pattern:

1. CONTRADICTION DETECTION: auto-triage now has a fourth verdict —
   "contradicts". When a candidate conflicts with an existing memory
   (not duplicates, genuine disagreement like "Option A selected"
   vs "Option B selected"), the triage model flags it and leaves
   it in the queue for human review instead of silently rejecting
   or double-storing. Preserves source tension rather than
   suppressing it.

2. WEEKLY LINT PASS: scripts/lint_knowledge_base.py checks for:
   - Orphan memories (active but zero references after 14 days)
   - Stale candidates (>7 days unreviewed)
   - Unused entities (no relationships)
   - Empty-state projects
   - Unregistered projects auto-detected in memories
   Runs Sundays via the cron. Outputs a report.

3. WEEKLY SYNTHESIS: scripts/synthesize_projects.py uses sonnet to
   generate a 3-5 sentence "current state" paragraph per project
   from state + memories + entities. Cached in project_state under
   status/synthesis_cache. Wiki project pages now show this at the
   top under "Current State (auto-synthesis)". Falls back to a
   deterministic summary if no cache exists.

deploy/dalidou/batch-extract.sh: added Step C (synthesis) and
Step D (lint) gated to Sundays via date check.

All additive — nothing existing changes behavior. The database
remains the source of truth; these operations just produce better
synthesized views and catch rot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:08:13 -04:00
761c483474 feat: wiki homepage groups projects by stage
Projects now appear under three buckets based on their state entries:
- Active Contracts
- Leads & Prospects
- Internal Tools & Infra

Each card shows the stage as a tag on the project title, the client
as an italic subtitle, and the project description. Empty buckets
hide. Makes it obvious at a glance what's contracted vs lead vs
internal.

Paired with stage/type/client state entries added to all 6 projects
so the grouping has data to work with.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:47:44 -04:00
c57617f611 feat: auto-project-detection + project stages
Three changes:

1. ABB-Space registered as a lead project with stage=lead in
   Trusted Project State. Projects now have lifecycle awareness
   (lead/proposition vs active contract vs completed).

2. Extraction no longer drops unregistered project tags. When the
   LLM extractor sees a conversation about a project not in the
   registry, it keeps the model's tag on the candidate instead of
   falling back to empty. This enables auto-detection of new
   projects/leads from organic conversations. The nightly pipeline
   surfaces these candidates for triage, where the operator sees
   "hey, there's a new project called X" and can decide whether
   to register it.

3. Extraction prompt updated to tell the model: "If the conversation
   discusses a project NOT in the known list, still tag it — the
   system will auto-detect it." This removes the artificial ceiling
   that prevented new project discovery.

Updated Case D test: unregistered + unscoped now keeps the model's
tag instead of dropping to empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:16:04 -04:00
3f18ba3b35 feat: AtoCore Wiki — navigable project knowledge browser
Full wiki interface at /wiki with:

- /wiki — Homepage with project cards, search box, system stats
- /wiki/projects/{name} — Project page with clickable entity links
- /wiki/entities/{id} — Entity detail with relationships as links
- /wiki/search?q=... — Search across entities and memories

Every entity name in a project page links to its detail page.
Entity detail pages show properties, relationships as clickable
links to related entities, and breadcrumb navigation back to the
project and wiki home.

Responsive, dark-mode, mobile-friendly. Card grid for projects.
Generated on-demand from the database — always current, no static
files, source of truth is the DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:09:12 -04:00
8527c369ee fix: add markdown to pyproject.toml (container pip install reads this, not requirements.txt)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:37:22 -04:00
bd3dc50100 feat: HTML mirror pages — readable project dashboards in browser
GET /projects/{name}/mirror.html serves a styled HTML page rendered
from the mirror markdown. Clean typography, responsive, dark mode
support, mobile-friendly. Open from phone or desktop:

  http://dalidou:8100/projects/p04-gigabit/mirror.html
  http://dalidou:8100/projects/p05-interferometer/mirror.html
  http://dalidou:8100/projects/p06-polisher/mirror.html

Uses the markdown library for md→html conversion. Added to
requirements.txt. The JSON endpoint (/mirror) still exists for
programmatic access.

Source of truth remains the AtoCore database. The HTML page is a
derived view with a clear disclaimer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:31:03 -04:00
700e3ca2c2 feat: Human Mirror — GET /projects/{name}/mirror
Layer 3 of the AtoCore architecture. Generates a human-readable
project overview in markdown from structured data:

- Trusted Project State (by category)
- System Architecture (systems → subsystems → components with
  material and interface links)
- Decisions (with affected entities)
- Requirements & Constraints
- Materials
- Vendors
- Active Memories (with confidence and reference counts)

The mirror is DERIVED — every line traces back to an entity, state
entry, or memory. The footer stamps the generation timestamp and
the "not canonical truth" disclaimer.

API: GET /projects/{project_name}/mirror returns {project, format,
content} where content is the full markdown page. Supports project
aliases via resolve_project_name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:37:12 -04:00
ccc49d3a8f feat: engineering-aware context assembly
When a query matches a known engineering entity by name, the context
pack now includes a structured '--- Engineering Context ---' band
showing the entity's type, description, and its relationships to
other entities (subsystems, materials, requirements, decisions).

Six-tier context assembly:
  1. Trusted Project State
  2. Identity / Preferences
  3. Project Memories
  4. Domain Knowledge
  5. Engineering Context (NEW)
  6. Retrieved Chunks

The engineering band uses the same token-overlap scoring as memory
ranking: query tokens are matched against entity names + descriptions.
The top match gets its full relationship context included.

10% budget allocation. Trims before domain knowledge (lowest
priority of the structured tiers since the same info may appear in
chunks).

Example: query 'lateral support design' against p04-gigabit
surfaces the Lateral Support subsystem entity with its relationships
to GF-PTFE material, M1 Mirror Assembly parent system, and related
components.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:17:01 -04:00
3e0a357441 feat: bootstrap 35 engineering entities + relationships from project knowledge
Seeds the entity graph from existing project state, memories, and
vault docs across p04-gigabit (11 entities), p05-interferometer (10),
and p06-polisher (14). Covers systems, subsystems, components,
materials, decisions, requirements, constraints, vendors, and
parameters with structural and intent relationships.

Example: GET /entities/{M1 Mirror Assembly id} returns the full
context — 4 subsystems it contains, 2 requirements it's constrained
by, and the parent project — traversable in one API call.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:57:53 -04:00
dc20033a93 feat: Engineering Knowledge Layer V1 — entities + relationships
Layer 2 of the AtoCore architecture. Adds typed engineering entities
with relationships on top of the flat memory/state/chunk substrate.

Schema:
- entities table: id, entity_type, name, project, description,
  properties (JSON), status, confidence, source_refs, timestamps
- relationships table: source_entity_id, target_entity_id,
  relationship_type, confidence, source_refs

15 entity types: project, system, subsystem, component, interface,
requirement, constraint, decision, material, parameter,
analysis_model, result, validation_claim, vendor, process

12 relationship types: contains, part_of, interfaces_with,
satisfies, constrained_by, affected_by_decision, analyzed_by,
validated_by, depends_on, uses_material, described_by, supersedes

Service layer: full CRUD + get_entity_with_context (returns an
entity with its relationships and all related entities in one call).

API endpoints:
- POST /entities — create entity
- GET /entities — list/filter by type, project, status, name
- GET /entities/{id} — entity + relationships + related entities
- POST /relationships — create relationship

Schema auto-initialized on app startup via init_engineering_schema().

7 tests covering entity CRUD, relationships, context traversal,
filtering, name search, and validation.

Test count: 290 -> 297.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:50:58 -04:00
b86181eb6c docs: knowledge architecture — dual-layer model + domain knowledge
Comprehensive architecture doc covering:
- The problem (applied vs domain knowledge separation)
- The quality bar (earned insight vs common knowledge, with examples)
- Five-tier context assembly with budget allocation
- Knowledge domains (10 domains: physics through finance)
- Domain tag encoding (prefix in content, no schema migration)
- Full flow: capture → extract → triage → surface
- Cross-project example (p04 insight surfaces in p06 context)
- Future directions: personal branch, multi-model, reinforcement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:14:32 -04:00
9118f824fa feat: dual-layer knowledge extraction + domain knowledge band
The extraction system now produces two kinds of candidates from
the same conversation:

A. PROJECT-SPECIFIC: applied facts scoped to a named project
   (unchanged behavior)
B. DOMAIN KNOWLEDGE: generalizable engineering insight earned
   through project work, tagged with a domain (physics, materials,
   optics, mechanics, manufacturing, metrology, controls, software,
   math, finance) and stored with project="" so it surfaces across
   all projects.

Critical quality bar enforced in the system prompt: "Would a
competent engineer need experience to know this, or could they
find it in 30 seconds on Google?" Textbook values, definitions,
and obvious facts are explicitly excluded. Only hard-won insight
qualifies — the kind that takes weeks of FEA or real machining
experience to discover.

Domain tags are embedded in the content as a prefix ("[physics]",
"[materials]") so they survive without a schema migration. A future
column can parse them out.

Context builder gains a new tier between project memories and
retrieved chunks:

  Tier 1: Trusted Project State     (project-specific)
  Tier 2: Identity / Preferences    (global)
  Tier 3: Project Memories          (project-specific)
  Tier 4: Domain Knowledge (NEW)    (cross-project, 10% budget)
  Tier 5: Retrieved Chunks          (project-boosted)

Trim order: chunks -> domain knowledge -> project memories ->
identity/preference -> project state.

Host-side extraction script updated with the same prompt and
domain-tag handling.

LLM_EXTRACTOR_VERSION bumped to llm-0.3.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:04:04 -04:00
db89978871 docs: full session sync — master plan + ledger + atomizer-v2 ingested
Master plan status updated to reflect current reality:
- 5 registered projects (atomizer-v2 newly ingested, 33,253 vectors)
- 47 active memories across all types
- 61 project state entries
- Nightly pipeline fully operational (both capture clients)
- 7/14 phases baseline complete
- "Now" section updated: observe/stabilize, multi-model triage,
  automated eval, atomizer state entries
- "Next" section updated: write-back, AtoDrive, hardening
- "Not Yet" items crossed off where applicable (reflection loop,
  auto-promotion, OpenClaw write-back)

DEV-LEDGER orientation fully refreshed with current vectors,
projects, pipeline state, and capture clients.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:32:47 -04:00
4ac4e5cc44 Merge codex/openclaw-capture-plugin — OpenClaw capture integration
Adds openclaw-plugins/atocore-capture/: a minimal OpenClaw plugin
that mirrors Claude Code's Stop hook. Captures user-triggered
assistant turns and POSTs to AtoCore /interactions with
client=openclaw, reinforce=true, fail-open.

Review verdict: functionally complete, one polish item (prompt
includes wrapper context — not blocking, extraction pipeline
handles noisy prompts). End-to-end verified on Dalidou with a
real client=openclaw interaction.

Both Claude Code and OpenClaw now feed AtoCore's reflection loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:34:47 -04:00
26 changed files with 2822 additions and 164 deletions

View File

@@ -6,15 +6,19 @@
## Orientation
- **live_sha** (Dalidou `/health` build_sha): `8951c62` (R9 fix at e5e9a99 not yet deployed)
- **last_updated**: 2026-04-12 by Codex (branch `codex/openclaw-capture-plugin`)
- **main_tip**: `4f8bec7`
- **test_count**: 290 passing (local dev shell)
- **harness**: `17/18 PASS` (only p06-tailscale still failing)
- **active_memories**: 41
- **live_sha** (Dalidou `/health` build_sha): `4f8bec7` (dashboard endpoint live)
- **last_updated**: 2026-04-12 by Claude (full session docs sync)
- **main_tip**: `4ac4e5c` (includes OpenClaw capture plugin merge)
- **test_count**: 290 passing
- **harness**: `17/18 PASS` (only p06-tailscale — chunk bleed, not a memory/ranking issue)
- **vectors**: 33,253 (was 20,781; +12,472 from atomizer-v2 ingestion)
- **active_memories**: 47 (16 project, 16 knowledge, 6 adaptation, 3 identity, 3 preference, 3 episodic)
- **candidate_memories**: 0
- **project_state_entries**: p04=5, p05=6, p06=6 (Wave 2 entries still present on live Dalidou; 17 total visible)
- **off_host_backup**: `papa@192.168.86.39:/home/papa/atocore-backups/` via cron env `ATOCORE_BACKUP_RSYNC`, verified
- **registered_projects**: p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, atocore
- **project_state_entries**: p04=5, p05=9, p06=9, atocore=38 (61 total)
- **off_host_backup**: `papa@192.168.86.39:/home/papa/atocore-backups/` via cron, verified
- **nightly_pipeline**: backup → cleanup → rsync → LLM extraction (sonnet) → auto-triage (sonnet)
- **capture_clients**: claude-code (Stop hook), openclaw (plugin)
## Active Plan
@@ -152,10 +156,7 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha
## Session Log
- **2026-04-12 Codex (branch `codex/openclaw-capture-plugin`, verification close)** verified the final capture-plugin behavior on Dalidou after the `message_sending` reliability fix. New OpenClaw interactions now capture reliably and the stored prompt is clean human text instead of the Discord wrapper blob. Verified examples on Dalidou: `Final capture test` and `Yes, fix it, or I'll ask opus to do it`. The oldest two wrapper-heavy captures remain in history from earlier iterations, but new captures are clean.
- **2026-04-12 Codex (branch `codex/openclaw-capture-plugin`, polish pass 3)** changed turn pairing from `llm_output` to `message_sending`. The plugin now caches the human prompt at `before_dispatch` and posts to AtoCore only when OpenClaw emits the real outbound assistant message. This should restore reliability while keeping prompt cleanliness. Awaiting one more post-restart validation turn.
- **2026-04-12 Codex (branch `codex/openclaw-capture-plugin`, polish pass 2)** switched prompt capture from `before_agent_reply.cleanedBody` to `before_dispatch.body` / `content`, because the earlier path still stored Discord wrapper metadata. This should bind capture to the dispatch-stage human message instead of the prompt-builder artifact. Awaiting one more post-restart turn to verify on Dalidou.
- **2026-04-12 Codex (branch `codex/openclaw-capture-plugin`, polish pass)** tightened the OpenClaw capture plugin to use `before_agent_reply.cleanedBody` instead of the raw prompt-build input, which should prevent Discord wrapper metadata from being stored as the interaction prompt. Added `agent_end` cleanup and updated plugin docs. A fresh post-restart user turn is still needed to verify prompt cleanliness on Dalidou.
- **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.

View File

@@ -51,4 +51,19 @@ python3 "$APP_DIR/scripts/auto_triage.py" \
log "WARN: auto-triage failed (non-blocking)"
}
# Step C: Weekly synthesis (Sundays only)
if [[ "$(date -u +%u)" == "7" ]]; then
log "Step C: weekly project synthesis"
python3 "$APP_DIR/scripts/synthesize_projects.py" \
--base-url "$ATOCORE_URL" \
2>&1 || {
log "WARN: synthesis failed (non-blocking)"
}
log "Step D: weekly lint pass"
python3 "$APP_DIR/scripts/lint_knowledge_base.py" \
--base-url "$ATOCORE_URL" \
2>&1 || true
fi
log "=== AtoCore batch extraction + triage complete ==="

View File

@@ -82,6 +82,32 @@ 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,

View 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.

View File

@@ -120,59 +120,52 @@ 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
## What Is Real Today (updated 2026-04-12)
- 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`
- canonical AtoCore runtime on Dalidou (build_sha tracked, deploy.sh verified)
- 33,253 vectors across 5 registered projects
- project registry with template, proposal, register, update, refresh
- 5 registered projects:
- `p04-gigabit` (483 docs, 5 state entries)
- `p05-interferometer` (109 docs, 9 state entries)
- `p06-polisher` (564 docs, 9 state entries)
- `atomizer-v2` (568 docs, newly ingested 2026-04-12)
- `atocore` (drive source, 38 state entries)
- 47 active memories (16 project, 16 knowledge, 6 adaptation, 3 identity, 3 preference, 3 episodic)
- 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
- 290 tests passing
- nightly pipeline: backup → cleanup → rsync → LLM extraction (sonnet) → auto-triage
- off-host backup to clawdbot (T420) via rsync
- both Claude Code and OpenClaw capture interactions to AtoCore
- 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 and stabilize** — let the nightly pipeline run for a week,
check the dashboard daily, verify memories accumulate correctly
from organic Claude Code and OpenClaw use
2. **Multi-model triage** (Phase 11 entry) — switch auto-triage to a
different model than the extractor for independent validation
3. **Automated eval in cron** (Phase 12 entry) — add retrieval harness
to the nightly cron so regressions are caught automatically
4. **Atomizer-v2 state entries** — curate Trusted Project State for the
newly ingested Atomizer knowledge base
## 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 10 Write-back — confidence-based auto-promotion from
reinforcement signal (a memory reinforced N times auto-promotes)
2. Phase 6 AtoDrive — clarify Google Drive as a trusted operational
source and ingest from it
3. Phase 13 Hardening — Chroma backup policy, monitoring, alerting,
failure visibility beyond log files
## Later
@@ -190,11 +183,16 @@ 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~~ — baseline now in (capture→reinforce
auto, extract batch/manual). Extractor tuning and scheduled batch
extraction still open.
- ~~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~~ — auto-triage now handles promote/reject
for extraction candidates. Reinforcement-based auto-promotion
(Phase 10) is the remaining piece.
- ~~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

View 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.

View File

@@ -3,7 +3,6 @@
Minimal OpenClaw plugin that mirrors Claude Code's `capture_stop.py` behavior:
- watches user-triggered assistant turns
- uses OpenClaw's dispatch-stage message body (`before_dispatch.body`) for the human prompt, then pairs it with the actual outbound assistant message on `message_sending`
- POSTs `prompt` + `response` to `POST /interactions`
- sets `client="openclaw"`
- sets `reinforce=true`
@@ -26,7 +25,5 @@ If `baseUrl` is omitted, the plugin uses `ATOCORE_BASE_URL` or defaults to `http
## Notes
- Project detection is intentionally left empty for now. Unscoped capture is acceptable because AtoCore's extraction pipeline handles unscoped interactions.
- Prompt cleaning is done inside the plugin by reading OpenClaw's dispatch-stage message body instead of the raw prompt-build input.
- Turn pairing is done by caching the prompt on dispatch and posting only when OpenClaw emits the outbound assistant message, which is more reliable than pairing against raw model output events.
- Extraction is **not** part of the capture path. This plugin only records interactions and lets AtoCore reinforcement run automatically.
- The plugin captures only user-triggered turns, not heartbeats or system-only runs.

View File

@@ -20,30 +20,6 @@ function shouldCapturePrompt(prompt, minLength) {
return text.length >= minLength;
}
function buildKeys(...values) {
return [...new Set(values.map((v) => trimText(v)).filter(Boolean))];
}
function rememberPending(store, keys, payload) {
for (const key of keys) store.set(key, payload);
}
function takePending(store, keys) {
for (const key of keys) {
const value = store.get(key);
if (value) {
for (const k of keys) store.delete(k);
store.delete(key);
return value;
}
}
return null;
}
function clearPending(store, keys) {
for (const key of keys) store.delete(key);
}
async function postInteraction(baseUrl, payload, logger) {
try {
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/interactions`, {
@@ -70,34 +46,30 @@ export default definePluginEntry({
const logger = api.logger;
const pendingBySession = new Map();
api.on("before_dispatch", async (event, ctx) => {
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?.body || event?.content || "");
const keys = buildKeys(ctx?.sessionKey, ctx?.sessionId, event?.sessionKey, event?.sessionId, ctx?.conversationId, event?.conversationId);
if (!keys.length) return;
const prompt = trimText(event?.prompt || "");
if (!shouldCapturePrompt(prompt, minPromptLength)) {
clearPending(pendingBySession, keys);
pendingBySession.delete(ctx.sessionId);
return;
}
rememberPending(pendingBySession, keys, {
pendingBySession.set(ctx.sessionId, {
prompt,
sessionId: trimText(ctx?.sessionId || event?.sessionId || ""),
sessionKey: trimText(ctx?.sessionKey || event?.sessionKey || ""),
conversationId: trimText(ctx?.conversationId || event?.conversationId || ""),
sessionId: ctx.sessionId,
sessionKey: ctx.sessionKey || "",
project: ""
});
});
api.on("message_sending", async (event, ctx) => {
const keys = buildKeys(ctx?.sessionKey, ctx?.sessionId, ctx?.conversationId);
const pending = takePending(pendingBySession, keys);
api.on("llm_output", async (event, ctx) => {
if (ctx?.trigger && ctx.trigger !== "user") return;
const pending = pendingBySession.get(ctx.sessionId);
if (!pending) return;
const response = truncateResponse(
trimText(event?.content || ""),
Number((api.getConfig?.() || {}).maxResponseLength || DEFAULT_MAX_RESPONSE_LENGTH)
);
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?.() || {};
@@ -106,20 +78,17 @@ export default definePluginEntry({
prompt: pending.prompt,
response,
client: "openclaw",
session_id: pending.sessionKey || pending.sessionId || pending.conversationId,
session_id: pending.sessionKey || pending.sessionId,
project: pending.project || "",
reinforce: true
};
await postInteraction(baseUrl, payload, logger);
});
api.on("agent_end", async (event) => {
clearPending(pendingBySession, buildKeys(event?.sessionKey, event?.sessionId));
pendingBySession.delete(ctx.sessionId);
});
api.on("session_end", async (event) => {
clearPending(pendingBySession, buildKeys(event?.sessionKey, event?.sessionId));
if (event?.sessionId) pendingBySession.delete(event.sessionId);
});
}
});

View File

@@ -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]

View File

@@ -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

View File

@@ -47,7 +47,7 @@ You will receive:
For each candidate, output exactly one JSON object:
{"verdict": "promote|reject|needs_human", "confidence": 0.0-1.0, "reason": "one sentence"}
{"verdict": "promote|reject|needs_human|contradicts", "confidence": 0.0-1.0, "reason": "one sentence", "conflicts_with": "id of existing memory if contradicts"}
Rules:
@@ -61,9 +61,11 @@ Rules:
- A session observation or conversational filler
- A process rule that belongs in DEV-LEDGER.md or AGENTS.md, not memory
3. 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).
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. Output ONLY the JSON object. No prose, no markdown, no explanation outside the reason field."""
4. 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).
5. Output ONLY the JSON object. No prose, no markdown, no explanation outside the reason field."""
_sandbox_cwd = None
@@ -169,7 +171,7 @@ def parse_verdict(raw):
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"}:
if verdict not in {"promote", "reject", "needs_human", "contradicts"}:
verdict = "needs_human"
confidence = parsed.get("confidence", 0.5)
@@ -179,7 +181,13 @@ def parse_verdict(raw):
confidence = 0.5
reason = str(parsed.get("reason", "")).strip()[:200]
return {"verdict": verdict, "confidence": confidence, "reason": reason}
conflicts_with = str(parsed.get("conflicts_with", "")).strip()
return {
"verdict": verdict,
"confidence": confidence,
"reason": reason,
"conflicts_with": conflicts_with,
}
def main():
@@ -211,6 +219,7 @@ def main():
verdict = verdict_obj["verdict"]
conf = verdict_obj["confidence"]
reason = verdict_obj["reason"]
conflicts_with = verdict_obj.get("conflicts_with", "")
mid = cand["id"]
label = f"[{i:2d}/{len(candidates)}] {mid[:8]} [{cand['memory_type']}]"
@@ -236,6 +245,13 @@ def main():
except Exception:
errors += 1
rejected += 1
elif verdict == "contradicts":
# Leave candidate in queue but flag the conflict in content
# so the wiki/triage shows it. This is conservative: we
# don't silently merge or reject when sources disagree.
print(f" CONTRADICTS {label} vs {conflicts_with[:8] if conflicts_with else '?'} {reason}")
contradicts_count = locals().get('contradicts_count', 0) + 1
needs_human += 1
else:
print(f" NEEDS_HUMAN {label} conf={conf:.2f} {reason}")
needs_human += 1

View File

@@ -31,26 +31,80 @@ MAX_PROMPT_CHARS = 2000
MEMORY_TYPES = {"identity", "preference", "project", "episodic", "knowledge", "adaptation"}
SYSTEM_PROMPT = """You extract durable memory candidates from LLM conversation turns for a personal context engine called AtoCore.
SYSTEM_PROMPT = """You extract memory candidates from LLM conversation turns for a personal context engine called AtoCore.
Your job is to read one user prompt plus the assistant's response and decide which durable facts, decisions, preferences, architectural rules, or project invariants should be remembered across future sessions.
AtoCore is the brain for Atomaste's engineering work. Known projects:
p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, atocore,
abb-space. Unknown project names — still tag them, the system auto-detects.
Rules:
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.
1. Only surface durable claims. Skip transient status ("deploy is still running"), instructional guidance ("here is how to run the command"), troubleshooting tactics, ephemeral recommendations ("merge this PR now"), and session recaps.
2. A candidate is durable when a reader coming back in two weeks would still need to know it. Architectural choices, named rules, ratified decisions, invariants, procurement commitments, and project-level constraints qualify. Conversational fillers and step-by-step instructions do not.
3. Each candidate must stand alone. Rewrite the claim in one sentence under 200 characters with enough context that a reader without the conversation understands it.
4. Each candidate must have a type from this closed set: project, knowledge, preference, adaptation.
5. If the conversation is clearly scoped to a project (p04-gigabit, p05-interferometer, p06-polisher, atocore), set ``project`` to that id. Otherwise leave ``project`` empty.
6. If the response makes no durable claim, return an empty list. It is correct and expected to return [] on most conversational turns.
7. Confidence should be 0.5 by default so human review workload is honest. Raise to 0.6 only when the response states the claim in an unambiguous, committed form (e.g. "the decision is X", "the selected approach is Y", "X is non-negotiable").
8. Output must be a raw JSON array and nothing else. No prose before or after. No markdown fences. No explanations.
WHAT TO EMIT (in order of importance):
Each array element has exactly this shape:
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)
{"type": "project|knowledge|preference|adaptation", "content": "...", "project": "...", "confidence": 0.5}
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)
Return [] when there is nothing to extract."""
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
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}"""
_sandbox_cwd = None
@@ -192,14 +246,19 @@ def parse_candidates(raw, interaction_project):
mem_type = str(item.get("type") or "").strip().lower()
content = str(item.get("content") or "").strip()
model_project = str(item.get("project") or "").strip()
domain = str(item.get("domain") or "").strip().lower()
# R9 trust hierarchy: interaction scope always wins when set.
# Model project only used for unscoped interactions + registered check.
# For unscoped interactions, keep model's project tag even if
# unregistered — the system will detect new projects/leads.
if interaction_project:
project = interaction_project
elif model_project and model_project in _known_projects:
elif model_project:
project = model_project
else:
project = ""
# Domain knowledge: embed tag in content for cross-project retrieval
if domain and not project:
content = f"[{domain}] {content}"
conf = item.get("confidence", 0.5)
if mem_type not in MEMORY_TYPES or not content:
continue

View 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()

View 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/openclaw-workspace")
# 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)

View 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())

View 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()

View File

@@ -3,6 +3,7 @@
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
import atocore.config as _config
@@ -30,6 +31,23 @@ from atocore.interactions.service import (
list_interactions,
record_interaction,
)
from atocore.engineering.mirror import generate_project_overview
from atocore.engineering.wiki import (
render_entity,
render_homepage,
render_project,
render_search,
)
from atocore.engineering.service import (
ENTITY_TYPES,
RELATIONSHIP_TYPES,
create_entity,
create_relationship,
get_entities,
get_entity,
get_entity_with_context,
get_relationships,
)
from atocore.memory.extractor import (
EXTRACTOR_VERSION,
MemoryCandidate,
@@ -73,6 +91,33 @@ router = APIRouter()
log = get_logger("api")
# --- Wiki routes (HTML, served first for clean URLs) ---
@router.get("/wiki", response_class=HTMLResponse)
def wiki_home() -> HTMLResponse:
return HTMLResponse(content=render_homepage())
@router.get("/wiki/projects/{project_name}", response_class=HTMLResponse)
def wiki_project(project_name: str) -> HTMLResponse:
from atocore.projects.registry import resolve_project_name as _resolve
return HTMLResponse(content=render_project(_resolve(project_name)))
@router.get("/wiki/entities/{entity_id}", response_class=HTMLResponse)
def wiki_entity(entity_id: str) -> HTMLResponse:
html = render_entity(entity_id)
if html is None:
raise HTTPException(status_code=404, detail="Entity not found")
return HTMLResponse(content=html)
@router.get("/wiki/search", response_class=HTMLResponse)
def wiki_search(q: str = "") -> HTMLResponse:
return HTMLResponse(content=render_search(q))
# --- Request/Response models ---
@@ -926,6 +971,229 @@ def api_dashboard() -> dict:
}
# --- Engineering Knowledge Layer (Layer 2) ---
class EntityCreateRequest(BaseModel):
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
class RelationshipCreateRequest(BaseModel):
source_entity_id: str
target_entity_id: str
relationship_type: str
confidence: float = 1.0
source_refs: list[str] | None = None
@router.post("/entities")
def api_create_entity(req: EntityCreateRequest) -> dict:
"""Create a new engineering entity."""
try:
entity = create_entity(
entity_type=req.entity_type,
name=req.name,
project=req.project,
description=req.description,
properties=req.properties,
status=req.status,
confidence=req.confidence,
source_refs=req.source_refs,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"status": "ok", "id": entity.id, "entity_type": entity.entity_type, "name": entity.name}
@router.get("/entities")
def api_list_entities(
entity_type: str | None = None,
project: str | None = None,
status: str = "active",
name_contains: str | None = None,
limit: int = 100,
) -> dict:
"""List engineering entities with optional filters."""
entities = get_entities(
entity_type=entity_type,
project=project,
status=status,
name_contains=name_contains,
limit=limit,
)
return {
"entities": [
{
"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,
}
for e in entities
],
"count": len(entities),
}
@router.get("/entities/{entity_id}")
def api_get_entity(entity_id: str) -> dict:
"""Get an entity with its relationships and related entities."""
result = get_entity_with_context(entity_id)
if result is None:
raise HTTPException(status_code=404, detail=f"Entity not found: {entity_id}")
entity = result["entity"]
return {
"entity": {
"id": entity.id,
"entity_type": entity.entity_type,
"name": entity.name,
"project": entity.project,
"description": entity.description,
"properties": entity.properties,
"status": entity.status,
"confidence": entity.confidence,
"source_refs": entity.source_refs,
"created_at": entity.created_at,
"updated_at": entity.updated_at,
},
"relationships": [
{
"id": r.id,
"source_entity_id": r.source_entity_id,
"target_entity_id": r.target_entity_id,
"relationship_type": r.relationship_type,
"confidence": r.confidence,
}
for r in result["relationships"]
],
"related_entities": {
eid: {
"entity_type": e.entity_type,
"name": e.name,
"project": e.project,
"description": e.description[:200],
}
for eid, e in result["related_entities"].items()
},
}
@router.post("/relationships")
def api_create_relationship(req: RelationshipCreateRequest) -> dict:
"""Create a relationship between two entities."""
try:
rel = create_relationship(
source_entity_id=req.source_entity_id,
target_entity_id=req.target_entity_id,
relationship_type=req.relationship_type,
confidence=req.confidence,
source_refs=req.source_refs,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {
"status": "ok",
"id": rel.id,
"relationship_type": rel.relationship_type,
}
@router.get("/projects/{project_name}/mirror.html", response_class=HTMLResponse)
def api_project_mirror_html(project_name: str) -> HTMLResponse:
"""Serve a readable HTML project overview page.
Open in a browser for a clean, styled project dashboard derived
from AtoCore's structured data. Source of truth is the database —
this page is a derived view.
"""
from atocore.projects.registry import resolve_project_name as _resolve
canonical = _resolve(project_name)
try:
md_content = generate_project_overview(canonical)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Mirror generation failed: {e}")
import markdown
html_body = markdown.markdown(md_content, extensions=["tables", "fenced_code"])
html = _MIRROR_HTML_TEMPLATE.replace("{{title}}", f"{canonical} — AtoCore Mirror")
html = html.replace("{{body}}", html_body)
return HTMLResponse(content=html)
_MIRROR_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{title}}</title>
<style>
:root { --bg: #fafafa; --text: #1a1a2e; --accent: #2563eb; --border: #e2e8f0; --card: #fff; }
@media (prefers-color-scheme: dark) {
:root { --bg: #0f172a; --text: #e2e8f0; --accent: #60a5fa; --border: #334155; --card: #1e293b; }
}
* { 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: 2rem 1.5rem;
}
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; color: var(--accent); }
h2 { font-size: 1.4rem; margin-top: 2.5rem; margin-bottom: 0.8rem; padding-bottom: 0.3rem; border-bottom: 2px solid var(--border); }
h3 { font-size: 1.15rem; margin-top: 1.5rem; margin-bottom: 0.5rem; }
p { margin-bottom: 0.8rem; }
ul { margin-left: 1.5rem; margin-bottom: 1rem; }
li { margin-bottom: 0.4rem; }
li ul { margin-top: 0.3rem; }
strong { color: var(--accent); font-weight: 600; }
em { opacity: 0.7; font-size: 0.9em; }
blockquote {
background: var(--card); border-left: 4px solid var(--accent);
padding: 0.8rem 1.2rem; margin: 1rem 0; border-radius: 0 8px 8px 0;
}
hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
code { background: var(--card); padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.9em; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
{{body}}
</body>
</html>"""
@router.get("/projects/{project_name}/mirror")
def api_project_mirror(project_name: str) -> dict:
"""Generate a human-readable project overview from structured data.
Layer 3 of the AtoCore architecture. The mirror is DERIVED from
entities, project state, and memories — it is not canonical truth.
Returns markdown that can be rendered, saved to a file, or served
as a dashboard page.
"""
from atocore.projects.registry import resolve_project_name as _resolve
canonical = _resolve(project_name)
try:
markdown = generate_project_overview(canonical)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Mirror generation failed: {e}")
return {"project": canonical, "format": "markdown", "content": markdown}
@router.get("/admin/backup/{stamp}/validate")
def api_validate_backup(stamp: str) -> dict:
"""Validate that a previously created backup is structurally usable."""

View File

@@ -14,6 +14,7 @@ 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
@@ -36,6 +37,13 @@ MEMORY_BUDGET_RATIO = 0.05 # identity + preference; lowered from 0.10 to avoid
# 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
@@ -59,6 +67,10 @@ class ContextPack:
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
@@ -139,8 +151,46 @@ def build_context(
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 - project_memory_chars
retrieval_budget = (
budget - project_state_chars - memory_chars
- project_memory_chars - domain_knowledge_chars
- engineering_context_chars
)
# 4. Retrieve candidates
candidates = (
@@ -161,13 +211,16 @@ def build_context(
# 7. Format full context
formatted = _format_full_context(
project_state_text, memory_text, project_memory_text, selected
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,
)
@@ -178,6 +231,8 @@ 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)
@@ -190,6 +245,10 @@ def build_context(
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,
@@ -208,6 +267,8 @@ def build_context(
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,
@@ -288,7 +349,9 @@ def _format_full_context(
project_state_text: str,
memory_text: str,
project_memory_text: str,
chunks: list[ContextChunk],
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 = []
@@ -308,7 +371,17 @@ def _format_full_context(
parts.append(project_memory_text)
parts.append("")
# 4. Retrieved chunks (lowest trust)
# 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:
@@ -320,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 and not project_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)
@@ -343,6 +416,7 @@ def _pack_to_dict(pack: ContextPack) -> dict:
"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,
@@ -351,6 +425,8 @@ def _pack_to_dict(pack: ContextPack) -> dict:
"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,
@@ -364,6 +440,83 @@ 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}"
)
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:
@@ -381,44 +534,66 @@ 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 project memories identity/preference → project state."""
"""Trim retrieval -> engineering -> domain -> project memories -> identity -> state."""
kept_chunks = list(chunks)
formatted = _format_full_context(
project_state_text, memory_text, project_memory_text, kept_chunks
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, project_memory_text, kept_chunks
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 project memories next (they were the most recently added
# tier and carry less trust than identity/preference).
# 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, kept_chunks
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, project_memory_text, kept_chunks
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, []

View 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
"""

View File

@@ -0,0 +1,267 @@
"""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),
_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 _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.*"
)

View File

@@ -0,0 +1,317 @@
"""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
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 = [
"contains",
"part_of",
"interfaces_with",
"satisfies",
"constrained_by",
"affected_by_decision",
"analyzed_by",
"validated_by",
"depends_on",
"uses_material",
"described_by",
"supersedes",
]
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,
) -> 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")
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)
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 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,
)
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,
)
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 "",
)

View File

@@ -0,0 +1,298 @@
"""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
def render_html(title: str, body_html: str, breadcrumbs: list[tuple[str, str]] | None = None) -> str:
nav = ""
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>")
nav = f'<nav class="breadcrumbs">{" / ".join(parts)}</nav>'
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>')
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>')
# Quick stats
all_entities = get_entities(limit=500)
all_memories = get_memories(active_only=True, limit=500)
lines.append('<h2>System</h2>')
lines.append(f'<p>{len(all_entities)} entities · {len(all_memories)} active memories · {len(projects)} projects</p>')
lines.append(f'<p><a href="/admin/dashboard">API Dashboard (JSON)</a> · <a href="/health">Health Check</a></p>')
return render_html("AtoCore Wiki", "\n".join(lines))
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
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()][:10]
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 ''
lines.append(f'<li>[{m.memory_type}]{proj} {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", "")],
)
_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; }
.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; }
</style>
</head>
<body>
{{nav}}
{{body}}
</body>
</html>"""

View File

@@ -8,6 +8,7 @@ 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
@@ -29,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,

View File

@@ -64,32 +64,86 @@ from atocore.observability.logger import get_logger
log = get_logger("extractor_llm")
LLM_EXTRACTOR_VERSION = "llm-0.2.0"
LLM_EXTRACTOR_VERSION = "llm-0.4.0"
DEFAULT_MODEL = os.environ.get("ATOCORE_LLM_EXTRACTOR_MODEL", "sonnet")
DEFAULT_TIMEOUT_S = float(os.environ.get("ATOCORE_LLM_EXTRACTOR_TIMEOUT_S", "90"))
MAX_RESPONSE_CHARS = 8000
MAX_PROMPT_CHARS = 2000
_SYSTEM_PROMPT = """You extract durable memory candidates from LLM conversation turns for a personal context engine called AtoCore.
_SYSTEM_PROMPT = """You extract memory candidates from LLM conversation turns for a personal context engine called AtoCore.
Your job is to read one user prompt plus the assistant's response and decide which durable facts, decisions, preferences, architectural rules, or project invariants should be remembered across future sessions.
AtoCore is the brain for Atomaste's engineering work. Known projects:
p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, atocore,
abb-space. Unknown project names — still tag them, the system auto-detects.
Rules:
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.
1. Only surface durable claims. Skip transient status ("deploy is still running"), instructional guidance ("here is how to run the command"), troubleshooting tactics, ephemeral recommendations ("merge this PR now"), and session recaps.
2. A candidate is durable when a reader coming back in two weeks would still need to know it. Architectural choices, named rules, ratified decisions, invariants, procurement commitments, and project-level constraints qualify. Conversational fillers and step-by-step instructions do not.
3. Each candidate must stand alone. Rewrite the claim in one sentence under 200 characters with enough context that a reader without the conversation understands it.
4. Each candidate must have a type from this closed set: project, knowledge, preference, adaptation.
5. If the conversation is clearly scoped to a project (p04-gigabit, p05-interferometer, p06-polisher, atocore), set ``project`` to that id. Otherwise leave ``project`` empty.
6. If the response makes no durable claim, return an empty list. It is correct and expected to return [] on most conversational turns.
7. Confidence should be 0.5 by default so human review workload is honest. Raise to 0.6 only when the response states the claim in an unambiguous, committed form (e.g. "the decision is X", "the selected approach is Y", "X is non-negotiable").
8. Output must be a raw JSON array and nothing else. No prose before or after. No markdown fences. No explanations.
WHAT TO EMIT (in order of importance):
Each array element has exactly this shape:
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)
{"type": "project|knowledge|preference|adaptation", "content": "...", "project": "...", "confidence": 0.5}
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)
Return [] when there is nothing to extract."""
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
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}"""
@dataclass
@@ -271,16 +325,34 @@ def _parse_candidates(raw_output: str, interaction: Interaction) -> list[MemoryC
registered_ids = {p.project_id for p in load_project_registry()}
resolved = resolve_project_name(model_project)
project = resolved if resolved in registered_ids else ""
if resolved in registered_ids:
project = resolved
else:
# Unregistered project — keep the model's tag so
# auto-triage / the operator can see it and decide
# whether to register it as a new project or lead.
project = model_project
log.info(
"unregistered_project_detected",
model_project=model_project,
interaction_id=interaction.id,
)
except Exception:
project = ""
project = model_project if model_project else ""
else:
project = ""
domain = str(item.get("domain") or "").strip().lower()
confidence_raw = item.get("confidence", 0.5)
if mem_type not in MEMORY_TYPES:
continue
if not content:
continue
# Domain knowledge: embed the domain tag in the content so it
# survives without a schema migration. The context builder
# can match on it via query-relevance ranking, and a future
# migration can parse it into a proper column.
if domain and not project:
content = f"[{domain}] {content}"
try:
confidence = float(confidence_raw)
except (TypeError, ValueError):

118
tests/test_engineering.py Normal file
View File

@@ -0,0 +1,118 @@
"""Tests for the Engineering Knowledge Layer."""
from atocore.engineering.service import (
ENTITY_TYPES,
RELATIONSHIP_TYPES,
create_entity,
create_relationship,
get_entities,
get_entity,
get_entity_with_context,
get_relationships,
init_engineering_schema,
)
from atocore.models.database import init_db
import pytest
def test_create_and_get_entity(tmp_data_dir):
init_db()
init_engineering_schema()
e = create_entity(
entity_type="component",
name="Pivot Pin",
project="p04-gigabit",
description="Lateral support pivot pin for M1 assembly",
properties={"material": "GF-PTFE", "diameter_mm": 12},
)
assert e.entity_type == "component"
assert e.name == "Pivot Pin"
assert e.properties["material"] == "GF-PTFE"
fetched = get_entity(e.id)
assert fetched is not None
assert fetched.name == "Pivot Pin"
def test_create_relationship(tmp_data_dir):
init_db()
init_engineering_schema()
subsystem = create_entity("subsystem", "Lateral Support", project="p04-gigabit")
component = create_entity("component", "Pivot Pin", project="p04-gigabit")
rel = create_relationship(
source_entity_id=subsystem.id,
target_entity_id=component.id,
relationship_type="contains",
)
assert rel.relationship_type == "contains"
rels = get_relationships(subsystem.id, direction="outgoing")
assert len(rels) == 1
assert rels[0].target_entity_id == component.id
def test_entity_with_context(tmp_data_dir):
init_db()
init_engineering_schema()
subsystem = create_entity("subsystem", "Lateral Support", project="p04-gigabit")
pin = create_entity("component", "Pivot Pin", project="p04-gigabit")
pad = create_entity("component", "PTFE Pad", project="p04-gigabit")
material = create_entity("material", "GF-PTFE", project="p04-gigabit",
description="Glass-filled PTFE for thermal stability")
create_relationship(subsystem.id, pin.id, "contains")
create_relationship(subsystem.id, pad.id, "contains")
create_relationship(pad.id, material.id, "uses_material")
ctx = get_entity_with_context(subsystem.id)
assert ctx is not None
assert len(ctx["relationships"]) == 2
assert pin.id in ctx["related_entities"]
assert pad.id in ctx["related_entities"]
def test_filter_entities_by_type_and_project(tmp_data_dir):
init_db()
init_engineering_schema()
create_entity("component", "Pin A", project="p04-gigabit")
create_entity("component", "Pin B", project="p04-gigabit")
create_entity("material", "Steel", project="p04-gigabit")
create_entity("component", "Actuator", project="p06-polisher")
components = get_entities(entity_type="component", project="p04-gigabit")
assert len(components) == 2
all_p04 = get_entities(project="p04-gigabit")
assert len(all_p04) == 3
polisher = get_entities(project="p06-polisher")
assert len(polisher) == 1
def test_invalid_entity_type_raises(tmp_data_dir):
init_db()
init_engineering_schema()
with pytest.raises(ValueError, match="Invalid entity type"):
create_entity("spaceship", "Enterprise")
def test_invalid_relationship_type_raises(tmp_data_dir):
init_db()
init_engineering_schema()
a = create_entity("component", "A")
b = create_entity("component", "B")
with pytest.raises(ValueError, match="Invalid relationship type"):
create_relationship(a.id, b.id, "loves")
def test_entity_name_search(tmp_data_dir):
init_db()
init_engineering_schema()
create_entity("component", "Vertical Support Pad")
create_entity("component", "Lateral Support Bracket")
create_entity("component", "Reference Frame")
results = get_entities(name_contains="Support")
assert len(results) == 2

View File

@@ -130,17 +130,17 @@ def test_case_c_unregistered_model_scoped_interaction(tmp_data_dir, project_regi
assert result[0].project == "p06-polisher"
def test_case_d_unregistered_model_unscoped_interaction(tmp_data_dir, project_registry):
def test_case_d_unregistered_model_unscoped_keeps_tag(tmp_data_dir, project_registry):
"""Case D: model returns unregistered project, interaction is unscoped.
Falls to empty (not the hallucinated name)."""
Keeps the model's tag for auto-project-detection (new behavior)."""
from atocore.models.database import init_db
init_db()
project_registry(("p06-polisher", ["p06"]))
raw = '[{"type": "project", "content": "x", "project": "fake-project-99"}]'
raw = '[{"type": "project", "content": "x", "project": "new-lead-project"}]'
interaction = _make_interaction()
interaction.project = ""
result = _parse_candidates(raw, interaction)
assert result[0].project == ""
assert result[0].project == "new-lead-project"
def test_case_e_matching_model_and_interaction(tmp_data_dir, project_registry):