Compare commits

...
Author SHA1 Message Date
Nicolò Boschi aa9c6f907c docs: add mermaid diagram source and PNG for self-learning loop 2026-04-27 17:24:16 +02:00
Nicolò Boschi b82f2df8cc test: 14 integration tests + README for Rust hindsight-agent 2026-04-27 16:25:46 +02:00
Nicolò Boschi 45273a34e7 chore: add .gitignore for hindsight-agent-rs, remove build artifacts 2026-04-27 15:08:39 +02:00
Nicolò Boschi 97b71cc8a0 feat: hindsight-agent Rust CLI — single binary for agent wiki + setup
Commands: setup, agents list/show, wiki list/get/create/update/delete,
recall, ingest, documents, retain. Agent-first interface, no bank knowledge.
Config at ~/.hindsight-agent/config.json. Compatible with existing Python config.
2026-04-27 15:07:48 +02:00
Nicolò Boschi a50c7616a3 fix(skill): tell agent to never summarize before ingesting, use -f for large content 2026-04-24 18:13:10 +02:00
Nicolò Boschi a3504158cb fix: make page ID a required positional argument on create 2026-04-24 18:10:41 +02:00
Nicolò Boschi 15eb7cfb8e feat(hermes): use append mode — retain each turn immediately, no buffering 2026-04-24 17:43:36 +02:00
Nicolò Boschi 0fcd0fdb60 fix(hermes): retain on every turn — gateway creates new provider instances, buffer is lost 2026-04-24 17:29:40 +02:00
Nicolò Boschi 123022bef6 feat: add ingest-document command for direct content upload 2026-04-24 17:27:51 +02:00
Nicolò Boschi 81f05c1c9c feat(hermes): periodic retain by turn count (every 5) and time (every 2min) 2026-04-24 17:20:46 +02:00
Nicolò Boschi 123561cd55 fix(hermes): flush buffered turns on reinit — gateway reuses provider without calling on_session_end 2026-04-24 17:18:37 +02:00
Nicolò Boschi 10d4b81ac7 feat(hermes): auto-patch SOUL.md, improve capture guidance in skill 2026-04-24 17:14:49 +02:00
Nicolò Boschi 28e499ed4f fix: create_page defaults to observation-only fact type 2026-04-24 17:11:07 +02:00
Nicolò Boschi d11d77d151 feat: add 'hindsight-agent list' command, skill self-resolves agent ID
- New 'list' command shows all configured agents
- Skill tells agent how to find its own ID (baked-in → profile name → list)
- Fixes shared-skill problem in Hermes multi-profile setups
2026-04-24 17:07:41 +02:00
Nicolò Boschi f38eda346d fix(hermes): resolve real HOME at import time, not at runtime 2026-04-24 16:53:34 +02:00
Nicolò Boschi ec67d9df0e feat: health check before setup, clear error if Hindsight is unreachable 2026-04-24 12:13:56 +02:00
Nicolò Boschi 573c60d0c6 fix(hermes): auto-create profile, profile-scoped config, correct chat instructions 2026-04-24 11:53:53 +02:00
Nicolò Boschi 80fc8fa438 fix: hermes setup shows correct chat command instead of gateway restart 2026-04-24 11:51:15 +02:00
Nicolò Boschi 9879519a90 fix(hermes): strict multi-profile resolution, warn on ambiguous match 2026-04-24 11:49:28 +02:00
Nicolò Boschi 009b53a3d7 fix(hermes): correct plugin discovery path and agent ID resolution
- Plugin name uses underscore (hindsight_agent) to match Hermes conventions
- Install to ~/.hermes/hermes-agent/plugins/memory/ (not ~/.hermes/plugins/)
- Fallback agent resolution: if agent_identity doesn't match, find any
  hermes-harness agent in config
- Auto-set memory.provider via hermes config set
- Tested end-to-end: setup → chat → retain → recall works
2026-04-24 11:45:14 +02:00
Nicolò Boschi b0586bcbf4 feat: add Hermes harness support to hindsight-agent
- New memory provider plugin at plugin/hermes/ implementing MemoryProvider ABC
  - Buffers turns via sync_turn, retains on session end (async)
  - Reads ~/.hindsight-agent/config.json for bank/URL/token
  - No tools or prefetch — skill handles reads via CLI
- setup --harness hermes: installs skill to ~/.hermes/skills/,
  copies plugin to ~/.hermes/plugins/hindsight-agent/
- Updated README with Hermes plugin docs
2026-04-24 11:36:04 +02:00
Nicolò Boschi 36442e0ff0 docs: add self-learning loop Excalidraw diagram 2026-04-23 10:51:35 +02:00
Nicolò Boschi fbb83cb2f7 paper: address resolvable questions, keep only experiments needing data
Resolved in PAPER_DRAFT.md:
- Added Generative Agents [8] as key related work with explicit comparison
- Added MemoryBank [9], A-MEM [10], practitioner tools (mem0, Letta, Zep)
- Qualified "100% capture" as "deterministic for completed sessions"
- Added failure mode analysis (Section 5.6): overlapping pages, contradictions,
  bad queries, adversarial content, unbounded accumulation
- Added privacy/deletion discussion (Section 5.7)
- Added cost analysis (Section 5.8)
- Added page discovery, versioning, pages vs recall sections
- Softened convergence claim to "empirically observed"
- Softened steerable claim to "observed in practice, formal analysis needed"
- Added thesis statement to introduction
- Standardized terminology: "synthesis query" (not source_query), "knowledge page"
- Sharpened "is this a new paradigm" argument (Section 5.5)
- Added production anecdote to Section 5.3
- Expanded limitations to 9 items with specific future work

Remaining in OPEN_QUESTIONS.md (12 items, all need experiments/data):
- Hard blockers: end-to-end quality benchmark, cross-session transfer numbers
- Should do: formalized write reliability, extraction faithfulness, real demo data
- Can acknowledge: phrasing stability, delta vs full, scaling, latency distribution
2026-04-22 14:04:30 +02:00
Nicolò Boschi 3a21a69db7 docs: move paper to paper/ dir, add OPEN_QUESTIONS.md with drafted answers
Comprehensive reviewer anticipation: empirical methodology gaps, missing
experiments, failure modes, architectural positions, related work (Generative
Agents, MemoryBank, A-MEM, practitioner tools), framing/positioning,
deployment data. Each question has a drafted answer based on our actual
experience building this.
2026-04-22 12:46:13 +02:00
Nicolò Boschi 4a7520699e docs: add PAPER_DRAFT.md — research paper on async knowledge synthesis for self-learning agents
Technical draft covering: unreliable writer problem, capture/synthesis separation,
source_query abstraction, comparison with file-based memory, pipeline auto-creation,
and Memento-Skills. References: Karpathy LLM Wiki, MemGPT, Reflexion, Voyager, CoALA.
2026-04-22 12:29:10 +02:00
Nicolò Boschi 8f56c22c27 cleanup: remove duplicate agent-knowledge skill (canonical copy is in hindsight-agent/skill/) 2026-04-22 12:26:10 +02:00
Nicolò Boschi 1582a3210c docs: add hindsight-agent README with all commands and deployment modes 2026-04-22 12:12:56 +02:00
Nicolò Boschi c6d5c3dd3f feat: API token support + sync skill files
- Add --api-token to setup (also reads HINDSIGHT_API_TOKEN env var)
- Store api_token in agent config, pass to all API calls (Bearer auth)
- OpenClaw plugin reads and passes token from config
- Sync skills/agent-knowledge/SKILL.md with hindsight-agent/skill/SKILL.md
2026-04-22 10:23:39 +02:00
Nicolò Boschi 4e6e9eba74 cleanup: remove KB remnants, agent-memory skill, hindsight-mount, demo templates
- Remove KB migration, tests, Rust CLI kb command and types
- Reset Rust CLI to main (only changes were KB-related)
- Remove agent-memory skill (superseded by agent-knowledge)
- Remove hindsight-mount script (superseded by direct CLI reads)
- Remove DEMO.md and marketing-seo templates (moved to nicolo-agents repo)
2026-04-22 10:20:37 +02:00
Nicolò Boschi 31eb2740fc refactor: remove Knowledge Base feature entirely
KB added complexity without value — each agent already has its own bank,
which provides the isolation boundary. The bank IS the grouping.

Removed from:
- API: KB CRUD endpoints, kb_id on MentalModelResponse, CreateMentalModelRequest,
  BankTemplateMentalModel, list_mental_models kb filter
- Engine: KB CRUD methods, _row_to_knowledge_base, kb_id on create/list MM,
  _run_knowledge_base_updates consolidation step
- Control plane: KB selector, KB badge, KB in create dialog, listKnowledgeBases,
  kb route proxy
- hindsight-agent: kb_id from config/api/setup/pages/template

Migration and test files left in place (DB will be erased).
2026-04-22 09:52:54 +02:00
Nicolò Boschi ae1a0a0e5e feat: hindsight-agent CLI, OpenClaw plugin, control plane KB UI
- hindsight-agent: Python CLI for agent scaffolding and runtime
  - setup: one-shot onboarding (bank, KB, skill, harness config, content ingestion)
  - retain: pipe content to Hindsight (always async)
  - pages: list/get/create/update/delete knowledge pages
  - recall: search memories for ad-hoc research
  - documents: list retained reference docs
  - --template: import bank template at setup
  - --content: ingest directory of reference docs at setup

- OpenClaw plugin: lightweight retain plugin
  - Reads ~/.hindsight-agent/config.json for bank/URL resolution
  - Filters to user/assistant text only
  - Retains async via Hindsight API

- Control plane: Knowledge Base UI
  - KB selector in mental models view
  - KB badge on cards, KB field in create dialog
  - kb_id in MentalModelResponse and BankTemplateMentalModel

- agent-knowledge skill: recall, documents, source_query patterns

- Remove broken _run_knowledge_base_updates reference from consolidator
2026-04-22 09:30:03 +02:00
Nicolò Boschi d3146abe9b docs: rewrite FINDINGS.md — key discoveries, current architecture, why each choice 2026-04-21 15:47:36 +02:00
Nicolò Boschi 0d101058f7 feat(cli): --kb flag on mental-model list + create; skill uses CLI everywhere 2026-04-21 15:47:36 +02:00
Nicolò Boschi cb8c700ec1 fix(agent-knowledge): use API for page create so kb_id is set 2026-04-21 15:47:36 +02:00
Nicolò Boschi 77f1163b04 feat(agent-knowledge): agent can update source_query, rename, and delete pages 2026-04-21 15:47:36 +02:00
Nicolò Boschi b844217db2 feat(agent-knowledge): agent creates pages, system refreshes them
Drop auto-create from the KB pipeline. The agent decides what pages
to create (same judgment it used with local files), the system keeps
them current via consolidation + refresh_after_consolidation.

The agent calls `mental-model create` with a good source_query when
it discovers a recurring topic. The system never creates pages on its
own — it only refreshes existing ones.

This fixes the junk-page problem: a cheap LLM reading decontextualized
observations can't distinguish signal from noise. The agent can,
because it has the full conversation context.
2026-04-21 15:47:36 +02:00
Nicolò Boschi 9457e5e383 feat: KB update can now DROP duplicate/junk pages + CREATE new ones in same call 2026-04-21 15:47:36 +02:00
Nicolò Boschi 299c534274 revert: remove code-level observation filter, rely on prompt only 2026-04-21 15:47:36 +02:00
Nicolò Boschi 843d448b44 fix: pre-filter noise observations before KB update LLM call 2026-04-21 15:47:36 +02:00
Nicolò Boschi 638198a514 fix: KB auto-create — default is [], require 3+ observations, much stricter prompt 2026-04-21 15:47:36 +02:00
Nicolò Boschi 6f5db0a938 fix: KB update prompt — strict mission scope, no agent internals or task output pages 2026-04-21 15:47:36 +02:00
Nicolò Boschi 328c54d2be fix: KB-created MMs use exclude_mental_models=true + mode=delta 2026-04-21 15:47:36 +02:00
Nicolò Boschi a53ced884b fix: KB update prompt — source_query must be a question, not raw observations 2026-04-21 15:47:36 +02:00
Nicolò Boschi 1a0924d008 fix: KB update LLM import + 16 tests passing + FINDINGS update
- Fix consolidator: use `llm_config.call()` instead of nonexistent
  `call_llm` import — the knowledge_base_update was crashing with
  ImportError on every consolidation run
- Pipeline tests: handle `no_new_memories` status (background worker
  may process memories before the test's explicit consolidation call)
- FINDINGS.md: document the current approach (KB + direct CLI reads),
  architecture diagram, what's implemented vs not, comparison table
2026-04-21 15:47:36 +02:00
Nicolò Boschi b3a017a421 feat(agent-knowledge): drop mount, use direct CLI calls for live reads 2026-04-21 15:47:36 +02:00
Nicolò Boschi db1b6ae01a fix(agent-knowledge): agent bootstraps its own KB on first session 2026-04-21 15:47:36 +02:00
Nicolò Boschi 9a8e67c7e7 fix: use app.state.memory for KB endpoints + add agent-knowledge skill
Fix NameError in KB HTTP endpoints — was using bare `engine` instead
of `app.state.memory` (copy-paste error from the endpoint template).

New skill: `agent-knowledge` — the Hindsight-backed read-only
counterpart to `agent-memory`. Agent mounts its KB as local files
via `hindsight-mount`, browses with normal tools, never writes.
The system handles all knowledge maintenance through the
retain → consolidation → KB update → MM refresh pipeline.
2026-04-21 15:47:36 +02:00
Nicolò Boschi 1e3cdc4a96 feat: knowledge_base_update operation + 13 KB tests 2026-04-21 15:47:36 +02:00
Nicolò Boschi 2d14a7d5c3 feat(cli): add hindsight kb subcommand — create/list/get/update/delete 2026-04-21 15:46:58 +02:00
Nicolò Boschi a906f55010 feat: KB wiring + hindsight-mount CLI
- Wire kb_id through create_mental_model engine method + HTTP endpoint
- Include kb_id in _row_to_mental_model output
- Include kb_id in list_mental_models SELECT
- Add scripts/hindsight-mount: renders a bank's mental models (optionally
  filtered by KB) as local markdown files at ~/.agent-knowledge/<bank>/
  with _index.md for browsing. Agent reads files with normal tools.
2026-04-21 15:46:58 +02:00
Nicolò Boschi b2e65d2286 feat: add Knowledge Base (KB) entity — CRUD, migration, API endpoints
New `knowledge_bases` table + KB CRUD in engine + HTTP endpoints +
`kb_id` filter on list_mental_models. See commit body for details.
2026-04-21 15:46:58 +02:00
Nicolò Boschi 9d5d9bd4ce docs(FINDINGS): Knowledge Base (KB) feature proposal for Hindsight 2026-04-21 15:46:57 +02:00
Nicolò Boschi 0b83cecd86 docs(FINDINGS): add LLM Wiki Maintainer PoC spec (Karpathy-inspired) 2026-04-21 15:46:57 +02:00
Nicolò Boschi 76443009d4 docs(FINDINGS): add requirements for the external system + Hindsight gap analysis 2026-04-21 15:46:57 +02:00
Nicolò Boschi ee1e266388 docs: add FINDINGS.md — self-improving agent skill research log 2026-04-21 15:46:57 +02:00
Nicolò Boschi fa6dfe779c fix(agent-memory): mandatory checklist at end of every turn
The agent knows the write rules but drops the post-response steps
because the LLM's natural stopping point is after the response.
Adding more prose to the "write after responding" section doesn't
help — the agent already read and agreed to the rule, then forgot.

Fix: force the agent to print a visible checklist as the LAST thing
it outputs every turn:

  📝 Memory: [wrote: <files> | logged: <yes/no> | committed: <yes/no>]

If the agent completed a task run and the checklist says "logged: no",
it has a bug and must fix it before ending the turn. The checklist is
a self-check that makes skipping the write step visibly wrong — both
to the agent (it has to fill in the fields) and to the user (they can
see if logging was missed).
2026-04-21 15:46:57 +02:00
Nicolò Boschi 1087bfbfe9 fix(agent-memory): memory is internal — never ask the user about file structure 2026-04-21 15:46:57 +02:00
Nicolò Boschi de52818087 fix(agent-memory): mandatory index repair, duplicate merge, strict write order
Incorporates the agent's own feedback on where the skill was ambiguous:

- If _index.md is missing but files exist, the agent MUST create it
  during the post-response phase of that same turn — not "later" or
  "next session". Leaving the directory without an index is a bug.

- Duplicate topic files (e.g. preferences.md + news-feed-preferences.md)
  are treated as hygiene errors: merge immediately during post-response,
  don't wait for the periodic self-review.

- Write order now has 4 explicit numbered steps:
  1. Respond (user sees their answer)
  2. Repair (missing index, duplicates, name normalization)
  3. Update (knowledge files + activity logs)
  4. Commit (git)

- Clarified that structural reads (checking if _index.md exists) are
  allowed at any time, but all writes (repairs included) happen after
  the user's response is delivered.
2026-04-21 15:46:57 +02:00
Nicolò Boschi 3e086a7c26 feat(agent-memory): git history, index file, post-response writes, self-review
Four additions to push the file-based approach to its ceiling:

1. Git: memory dir is a git repo, auto-committed after every write.
   Full diff history, rollback, blame — answers "when did this change"
   without relying on the evidence section alone.

2. Index file: _index.md maintained by the agent, one line per file
   with a short description. Agent reads the index first, then only
   the relevant files — scales retrieval to ~100 files before breaking.

3. Strict post-response writes: the skill now mandates respond first,
   write memory second, commit third. Memory updates are a visible
   post-script the user can see, not a silent side-effect or a blocker.

4. Periodic self-review: every ~10 sessions (or on user request) the
   agent reads ALL files and does a consolidation pass — resolve
   contradictions, merge duplicates, split overgrown files, prune
   stale log entries, flag facts without evidence. Committed as a
   single "memory review" git commit.

Also: evidence supersession now shown inline with ~~strikethrough~~ AND
preserved in git history, so both quick-scan and deep-dig work.
2026-04-21 15:46:57 +02:00
Nicolò Boschi 9e2c642626 fix(agent-memory): make activity logging a mandatory trigger, not optional 2026-04-21 15:46:57 +02:00
Nicolò Boschi 4bb9073092 feat(agent-memory): add activity logs for dedup + task history
Knowledge files track what the agent knows (preferences, rules, setup).
Activity logs track what the agent did (feed items delivered, sweep
results, user reactions). Append-only, dated entries, pruned at ~30 days.

The agent reads the log before each task run and skips items it already
delivered. Solves the "showed the same headline twice" problem and gives
the agent a "what did I show you yesterday" answer.
2026-04-21 15:46:57 +02:00
Nicolò Boschi 93b8766a41 fix(agent-memory): use ~/.agent-memory/<agent-name>/ outside workspace 2026-04-21 15:46:57 +02:00
Nicolò Boschi 572e6bc9c7 feat(skills): agent-memory — procedural wiki the agent builds itself
New skill that gives the agent a `memory/` directory in its workspace
where it creates and maintains markdown files — one per topic — as a
growing wiki of everything it learned across sessions.

Key design choices:
- Pure files, no external dependency. The agent is the only writer.
- One file per topic (user preferences, procedures, setup facts, etc.)
- Every fact must have an `## Evidence` section with dated entries
  citing the conversation/event that established it.
- Updates are in-place rewrites (current state of knowledge), not
  append-only logs. Superseded evidence is struck through, not deleted.
- Agent reads relevant memory files at the start of each task and
  treats them as ground truth unless the user contradicts them.
- Write-after-act: the agent finishes the user's task first, then
  updates memory. No mid-task interruptions for note-taking.

This is intentionally independent of Hindsight — it's the agent's own
procedural memory, self-organized, with built-in provenance via the
evidence trail. Can coexist with Hindsight-backed skills (which handle
semantic recall / consolidation / mental models) or stand alone.
2026-04-21 15:46:57 +02:00
50 changed files with 6621 additions and 10 deletions
+143
View File
@@ -0,0 +1,143 @@
# Self-Improving Agent Skills: Research Findings
Research log from building and testing procedural memory for OpenClaw agents (April 2026). Goal: understand where local file-based memory breaks and where an external system like Hindsight is genuinely needed.
Related work: [Memento-Skills (arXiv:2603.18743)](https://arxiv.org/abs/2603.18743) — "Let Agents Design Agents". Similar premise (skills as memory, read-write reflective learning), converges on the same insight: the agent needs a persistent, evolving knowledge store outside its context window.
---
## Key Discovery #1: Capture and synthesis must be separated
We built an `agent-memory` skill where the agent maintains its own wiki of markdown files — one per topic, with evidence sections, git-tracked, indexed. The agent reads before acting and writes after responding.
**What worked:** The agent reads memory files reliably and applies them well (dedup, preferences, procedures). The evidence trail provides basic provenance. Git gives full history.
**What broke:** The agent forgets to write. The LLM's natural stopping point is after the visible response — post-response writes (update files, append activity log, git commit) get dropped ~30% of the time. We tried a mandatory checklist (`📝 Memory: [wrote: X | logged: Y | committed: Z]`) which helped but didn't eliminate the problem.
**The insight:** Asking the agent to both produce output AND maintain its memory in the same turn is unreliable. Capture must be infrastructure-level and deterministic. Synthesis can be LLM-driven but should happen asynchronously, off the critical path.
This is exactly what Hindsight's architecture does: auto-retain (deterministic hook on every turn) + consolidation (async LLM synthesis in the background).
## Key Discovery #2: The agent is an excellent reader but an unreliable writer
Reading memory and applying it to tasks works well. The failure is consistently on writes — the agent understands the rules, agrees with them, then doesn't execute the post-response steps. Each session's agent is stateless except for what it explicitly reads; writes are an afterthought that competes with the primary task for attention.
**Why this matters for architecture:** The agent should be read-only on memory. All writes should come from infrastructure (hooks, pipelines) or from explicit agent actions that are part of the task itself (not post-task cleanup).
## Key Discovery #3: Auto-creating knowledge pages from raw observations doesn't work
We built a `knowledge_base_update` pipeline that runs after consolidation: it reads the KB mission + recent observations + existing pages, asks an LLM whether new pages should be created, and creates them.
**What broke:** The observations include everything retained from conversations — agent tool usage, delivered news content, identity setup, user names — not just user preferences. No matter how strict the prompt, the LLM creates pages for "Open Source AI Models" (from a news delivery) or "Agent Identity" (from setup chatter). We tried:
- Strict prompt rules ("NEVER create pages for delivered content") — LLM ignores them
- Code-level observation filters (pattern matching) — fragile, wrong approach
- Requiring 3+ observations per topic — still creates junk from clustered noise
**The insight:** A cheap LLM reading decontextualized observations cannot distinguish signal from noise. The agent can, because it has the full conversation context and understands what matters. Auto-creation works in Karpathy's LLM Wiki model because the input is curated documents, not raw conversation transcripts full of noise.
## Key Discovery #4: Karpathy's LLM Wiki pattern maps cleanly but the orchestrator should be the agent, not a pipeline
Karpathy's pattern: raw sources → LLM-maintained wiki → schema. Three operations: ingest, query, lint. The LLM does all the writing.
Our mapping: session transcripts → Hindsight KB (mental models) → agent skill. The agent reads; the system writes.
**Where Karpathy's model breaks for us:** His model assumes curated document inputs where the LLM can identify topic boundaries. Our inputs are raw conversation transcripts where 80% of the content is noise (tool calls, formatting, agent self-talk). A pipeline LLM can't curate this well enough.
**The fix:** Let the agent decide what pages to create (it has context), let the system keep them updated (it has reliability). This is the split we converged on.
---
## Current Architecture
```
[Agent conversation]
[auto-retain plugin hook] → captures every turn, deterministically
[Hindsight bank] → raw retained content
[consolidation] → extracts observations from raw content
[refresh_after_consolidation] → each MM re-runs its source_query
[updated mental model content]
[Agent reads via CLI] ← mental-model list/get
```
### Who does what
| Role | Agent | System (Hindsight) |
|---|---|---|
| **Capture** | Nothing — auto-retain handles it | Hook fires on every `agent_end`, deterministic |
| **Create pages** | Decides what topics need a page, calls `mental-model create` with a `source_query` | Stores the page, schedules initial content generation |
| **Read pages** | `mental-model list --kb <kb>` + `mental-model get <id>` | Returns current content |
| **Update page content** | Nothing — acknowledges user feedback in one sentence so retain captures it | Consolidation extracts observations → MM refresh re-synthesizes page content |
| **Update page scope** | `mental-model update <id> --source-query "..."` when the scope needs changing | Applies the new query on next refresh |
| **Delete pages** | `mental-model delete <id>` when a page is redundant | Removes it |
| **Organize pages** | Groups pages into a Knowledge Base (KB) via `--kb` flag | KB is a named collection with a mission, used for grouping |
### Why each choice
**Agent creates pages, not a pipeline.** Because the agent has conversation context and knows what's a recurring concern vs noise. The pipeline only sees decontextualized observations and creates junk pages (Discovery #3).
**System refreshes pages, not the agent.** Because the agent forgets to write (Discovery #2). `refresh_after_consolidation` runs automatically — no post-response step to forget.
**`source_query` is the key abstraction.** It's a question the system re-asks on every consolidation. The agent writes it once when creating the page; the system runs it forever. The agent controls *what* gets synthesized; the system handles *when* and *how*.
**Direct CLI reads, not mounted files.** We tried `hindsight-mount` (dump MMs to disk as markdown files). Dropped it because: files go stale the moment they're written, need re-mounting after consolidation, add a sync problem. Direct `mental-model get` is always live.
**KB groups pages but doesn't auto-create.** `auto_create: false`. The KB is a namespace (news-feed vs discord-watch), not an orchestrator. The agent decides the structure.
**Auto-retain with `retainToolCalls: false`.** Captures every conversation turn including user feedback. Tool calls excluded to reduce noise in retained content — tool results (file reads, web searches) pollute the observation space and cause the consolidation + MM refresh to synthesize irrelevant content.
### What's implemented
| Component | Status |
|---|---|
| Knowledge Base entity (CRUD, migration, API, CLI) | ✅ |
| `--kb` flag on `mental-model list` and `mental-model create` | ✅ |
| `knowledge_base_update` pipeline step (auto-create disabled) | ✅ |
| `agent-knowledge` skill (read + create + update + delete pages via CLI) | ✅ |
| Auto-retain via openclaw plugin | ✅ |
| Consolidation → MM refresh pipeline | ✅ (existing Hindsight feature) |
| 16 tests (KB CRUD + relationships + pipeline) | ✅ |
### What's NOT implemented (known gaps)
- **Tag-scoped observation routing** — all observations in the bank are visible to all MMs. Tag filtering would let MMs scope to relevant observations only.
- **`mental_model_ids` on recall results** — agent can't yet discover which MM covers a topic via recall. Has to scan the list.
- **Cross-agent KB sharing** — shared topics across agents require a cross-bank mechanism.
- **Activity log extraction** — mechanical parsing of "what was delivered" from session transcripts. Currently the agent has to notice and remember this itself.
- **Per-statement provenance** — each line in a MM tracing back to the observation(s) that produced it. The delta-mode MM work is heading here.
---
## Comparison: File-Based vs Hindsight-Backed
| Concern | Agent writes files | Hindsight KB + CLI reads |
|---|---|---|
| Capture reliability | Agent forgets ~30% of writes | Auto-retain hook, 100% reliable |
| Synthesis timing | Synchronous, blocks user's turn | Async (consolidation), off critical path |
| Read pattern | `cat ~/.agent-memory/topic.md` | `hindsight mental-model get <bank> <id>` |
| Staleness | Always current (agent just wrote it) | Minutes latency (consolidation cycle) |
| Scale | Index breaks at ~100 files | Semantic recall across any bank size |
| Agent complexity | Read + write + git + checklist | Read + create (one-time) |
| Infrastructure | Zero | Hindsight server + worker |
| Page creation quality | Agent decides (good) | Pipeline decides (bad) → switched to agent decides |
The async latency is the one trade-off. Within a session, the agent applies feedback from conversation context. Cross-session, the KB catches up via consolidation.
---
## Open Questions
1. **Can per-statement provenance work in practice?** Each line in a MM tracing to the observation(s) that produced it — "this rule came from turn 7 in session X". Requires the MM refresh to output cited fragments, not free text. The delta-mode work is the foundation.
2. **Should the agent also read via auto-recall injection?** Currently `autoRecall: false` — the agent reads pages via CLI. An alternative: the plugin injects relevant MM content into the system prompt at `before_prompt_build`, like it does with recalled memories. Zero agent effort, but burns context tokens.
3. **Will the source_query abstraction hold at scale?** With 3-5 pages it works well. At 50 pages, each MM refresh does a full reflect call — that's 50 LLM calls per consolidation. May need batching or incremental refresh.
4. **Cross-agent knowledge sharing.** User voice preferences, timezone, known tools — these apply to all agents. Need either a shared KB or cross-bank MM references.
5. **How does this compare to Memento-Skills' approach?** They let the agent rewrite skill files directly (with a judge + unit tests + rollback). We let the agent create pages but not edit content. Their approach is more autonomous but needs heavier infrastructure (judge, test gate, rollback). Ours is simpler but depends on the consolidation pipeline quality.
+1
View File
@@ -0,0 +1 @@
/target
+1849
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "hindsight-agent"
version = "0.1.0"
edition = "2021"
description = "Agent CLI for Hindsight Wiki — self-learning knowledge pages for AI agents"
license = "MIT"
[[bin]]
name = "hindsight-agent"
path = "src/main.rs"
[dependencies]
clap = { version = "4.5", features = ["derive", "env"] }
reqwest = { version = "0.12", features = ["json", "blocking"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
dirs = "6.0"
[dev-dependencies]
tempfile = "3"
+168
View File
@@ -0,0 +1,168 @@
# hindsight-agent
Single-binary CLI for [Hindsight Wiki](https://github.com/vectorize-io/hindsight) — self-learning knowledge pages for AI agents.
Agents get a persistent wiki that evolves from their conversations. The agent reads wiki pages at session startup, creates new pages when it discovers recurring topics, and the system keeps pages updated automatically via async consolidation.
## Install
```bash
# From source
cargo install --path .
# Or build and copy
cargo build --release
cp target/release/hindsight-agent ~/.local/bin/
```
## Quick start
```bash
# 1. Set up an agent (one-time)
hindsight-agent setup my-agent \
--bank-id my-bank \
--harness hermes \
--api-url http://localhost:8888
# 2. Create a wiki page
hindsight-agent wiki create my-agent user-prefs \
"User Preferences" \
"What are the user's preferences for tone, format, and content?"
# 3. Ingest a reference document
hindsight-agent ingest my-agent "Style Guide" -f style-guide.md
# 4. Search memories
hindsight-agent recall my-agent "what format does the user prefer"
# 5. List wiki pages
hindsight-agent wiki list my-agent
```
## How it works
```
User ↔ Agent conversation
Harness plugin retains conversation → Hindsight bank (async)
Consolidation extracts observations (background)
Each wiki page re-runs its synthesis query against new observations
Agent reads updated pages at next session startup
```
The agent decides **what** to track (creates pages with synthesis queries). The system handles **capture** (harness plugin) and **synthesis** (consolidation + page refresh).
## Commands
### `setup` — One-time agent onboarding
```bash
hindsight-agent setup <agent-id> \
--bank-id <bank> \
--harness hermes|openclaw \
[--api-url <url>] \
[--api-token <token>] \
[--template <template.json>] \
[--content <content-dir/>]
```
Creates the Hindsight bank, imports a template (optional), ingests reference docs (optional), configures the harness (creates Hermes profile or OpenClaw agent), and saves the agent config.
### `agents` — Manage agents
```bash
hindsight-agent agents list # show all configured agents
hindsight-agent agents show <agent> # show one agent's config
```
### `wiki` — Knowledge pages
```bash
hindsight-agent wiki list <agent>
hindsight-agent wiki get <agent> <page-id>
hindsight-agent wiki create <agent> <page-id> "<name>" "<synthesis-query>"
hindsight-agent wiki update <agent> <page-id> [--name "..."] [--source-query "..."]
hindsight-agent wiki delete <agent> <page-id>
```
Pages are created with opinionated defaults:
- `mode: delta` — only processes new observations per refresh
- `fact_types: [observation]` — synthesizes from observations only
- `exclude_mental_models: true` — pages don't feed into each other
- `refresh_after_consolidation: true` — auto-updates after each consolidation
### `recall` — Search memories
```bash
hindsight-agent recall <agent> "<query>" [-n 10] [--type observation]
```
### `ingest` — Upload documents
```bash
hindsight-agent ingest <agent> "<title>" -f document.md
hindsight-agent ingest <agent> "<title>" -c "inline content"
cat data.txt | hindsight-agent ingest <agent> "<title>"
```
### `documents` — List retained documents
```bash
hindsight-agent documents <agent>
```
### `retain` — Raw content retention
```bash
echo "content" | hindsight-agent retain <agent> [--document-id <id>]
hindsight-agent retain <agent> --input file.txt
```
## Config
Agent configs live at `~/.hindsight-agent/config.json`:
```json
{
"agents": {
"my-agent": {
"bank_id": "my-bank",
"api_url": "http://localhost:8888",
"api_token": "hst_...",
"harness": "hermes"
}
}
}
```
All commands resolve the agent ID to bank + API URL + token from this file. The agent never sees bank IDs.
## Connecting to Hindsight
```bash
# Local (default)
hindsight-agent setup my-agent --bank-id my-bank --harness hermes
# Self-hosted
hindsight-agent setup my-agent --bank-id my-bank --harness hermes \
--api-url https://hindsight.internal.company.com
# Cloud
hindsight-agent setup my-agent --bank-id my-bank --harness hermes \
--api-url https://api.hindsight.cloud \
--api-token hst_your_token
```
Environment variables `HINDSIGHT_API_URL` and `HINDSIGHT_API_TOKEN` are also supported.
## Harness support
| Harness | Setup creates | Retain method |
|---------|---------------|---------------|
| **Hermes** | Profile, sets memory provider | `hindsight_agent` memory plugin (sync_turn + on_session_end) |
| **OpenClaw** | Agent, registers plugin | `hindsight-agent` plugin (reads config, POSTs on agent_end) |
The skill (`agent-knowledge`) is harness-agnostic — it uses `hindsight-agent` CLI commands that work identically across harnesses.
+243
View File
@@ -0,0 +1,243 @@
//! HTTP client for Hindsight API.
//!
//! Thin wrapper over reqwest with agent config resolution.
use anyhow::{Context, Result};
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use serde_json::Value;
use crate::config::AgentConfig;
pub struct HindsightClient {
client: Client,
base_url: String,
bank_id: String,
}
impl HindsightClient {
pub fn from_agent(config: &AgentConfig) -> Result<Self> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
if let Some(token) = &config.api_token {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", token))
.context("Invalid API token")?,
);
}
let client = Client::builder()
.default_headers(headers)
.timeout(std::time::Duration::from_secs(60))
.build()?;
Ok(Self {
client,
base_url: config.api_url.trim_end_matches('/').to_string(),
bank_id: config.bank_id.clone(),
})
}
fn bank_url(&self, path: &str) -> String {
format!(
"{}/v1/default/banks/{}{}",
self.base_url, self.bank_id, path
)
}
// ── Health ──────────────────────────────────────────
pub fn health(&self) -> Result<()> {
let resp = self
.client
.get(format!("{}/health", self.base_url))
.send()
.context("Cannot reach Hindsight API")?;
if !resp.status().is_success() {
anyhow::bail!("Hindsight API unhealthy ({})", resp.status());
}
Ok(())
}
// ── Bank ────────────────────────────────────────────
pub fn ensure_bank(&self) -> Result<()> {
let resp = self
.client
.get(format!("{}/v1/default/banks", self.base_url))
.send()?;
if resp.status().is_success() {
let body: Value = resp.json()?;
if let Some(banks) = body["banks"].as_array() {
for bank in banks {
if bank["bank_id"].as_str() == Some(&self.bank_id) {
return Ok(());
}
}
}
}
// Create via empty retain
self.client
.post(self.bank_url("/memories"))
.json(&serde_json::json!({"items": [], "async": true}))
.send()?;
Ok(())
}
pub fn import_template(&self, template: &Value) -> Result<Value> {
let resp = self
.client
.post(self.bank_url("/import"))
.json(template)
.send()?;
if !resp.status().is_success() {
let body = resp.text().unwrap_or_default();
anyhow::bail!("Template import failed: {}", body);
}
Ok(resp.json()?)
}
// ── Wiki (Mental Models) ────────────────────────────
pub fn wiki_list(&self) -> Result<Value> {
let resp = self.client.get(self.bank_url("/mental-models")).send()?;
resp.error_for_status_ref()
.context("Failed to list wiki pages")?;
Ok(resp.json()?)
}
pub fn wiki_get(&self, page_id: &str) -> Result<Value> {
let resp = self
.client
.get(self.bank_url(&format!("/mental-models/{}", page_id)))
.send()?;
resp.error_for_status_ref()
.context(format!("Failed to get page '{}'", page_id))?;
Ok(resp.json()?)
}
pub fn wiki_create(
&self,
page_id: &str,
name: &str,
source_query: &str,
) -> Result<Value> {
let body = serde_json::json!({
"id": page_id,
"name": name,
"source_query": source_query,
"trigger": {
"mode": "delta",
"refresh_after_consolidation": true,
"exclude_mental_models": true,
"fact_types": ["observation"],
}
});
let resp = self
.client
.post(self.bank_url("/mental-models"))
.json(&body)
.send()?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_default();
anyhow::bail!("Failed to create page ({}): {}", status, text);
}
Ok(resp.json()?)
}
pub fn wiki_update(
&self,
page_id: &str,
name: Option<&str>,
source_query: Option<&str>,
) -> Result<Value> {
let mut body = serde_json::Map::new();
if let Some(n) = name {
body.insert("name".to_string(), Value::String(n.to_string()));
}
if let Some(sq) = source_query {
body.insert("source_query".to_string(), Value::String(sq.to_string()));
}
let resp = self
.client
.patch(self.bank_url(&format!("/mental-models/{}", page_id)))
.json(&Value::Object(body))
.send()?;
resp.error_for_status_ref()
.context(format!("Failed to update page '{}'", page_id))?;
Ok(resp.json()?)
}
pub fn wiki_delete(&self, page_id: &str) -> Result<()> {
let resp = self
.client
.delete(self.bank_url(&format!("/mental-models/{}", page_id)))
.send()?;
resp.error_for_status_ref()
.context(format!("Failed to delete page '{}'", page_id))?;
Ok(())
}
// ── Recall ──────────────────────────────────────────
pub fn recall(
&self,
query: &str,
max_results: u32,
types: &[String],
) -> Result<Value> {
let mut body = serde_json::json!({
"query": query,
"max_results": max_results,
});
if !types.is_empty() {
body["types"] = Value::Array(types.iter().map(|t| Value::String(t.clone())).collect());
}
let resp = self
.client
.post(self.bank_url("/memories/recall"))
.json(&body)
.send()?;
resp.error_for_status_ref().context("Recall failed")?;
Ok(resp.json()?)
}
// ── Ingest / Retain ─────────────────────────────────
pub fn retain(&self, content: &str, document_id: Option<&str>) -> Result<Value> {
let mut item = serde_json::json!({"content": content});
if let Some(doc_id) = document_id {
item["document_id"] = Value::String(doc_id.to_string());
}
let resp = self
.client
.post(self.bank_url("/memories"))
.json(&serde_json::json!({"items": [item], "async": true}))
.send()?;
resp.error_for_status_ref().context("Retain failed")?;
Ok(resp.json()?)
}
// ── Documents ───────────────────────────────────────
pub fn documents_list(&self) -> Result<Value> {
let resp = self.client.get(self.bank_url("/documents")).send()?;
resp.error_for_status_ref()
.context("Failed to list documents")?;
Ok(resp.json()?)
}
// ── Consolidation ───────────────────────────────────
pub fn consolidate(&self) -> Result<Value> {
let resp = self
.client
.post(self.bank_url("/consolidate"))
.send()?;
resp.error_for_status_ref()
.context("Failed to trigger consolidation")?;
Ok(resp.json()?)
}
}
+23
View File
@@ -0,0 +1,23 @@
//! Agent management commands.
use anyhow::Result;
use crate::config::Config;
pub fn list() -> Result<()> {
let config = Config::load()?;
if config.agents.is_empty() {
eprintln!("No agents configured. Run 'hindsight-agent setup' to add one.");
return Ok(());
}
let output = serde_json::to_string_pretty(&config.agents)?;
println!("{}", output);
Ok(())
}
pub fn show(agent_id: &str) -> Result<()> {
let config = Config::load()?;
let agent = config.get_agent(agent_id)?;
let output = serde_json::to_string_pretty(agent)?;
println!("{}", output);
Ok(())
}
@@ -0,0 +1,14 @@
//! Documents command — list retained documents.
use anyhow::Result;
use crate::api::HindsightClient;
use crate::config::Config;
pub fn list(agent_id: &str) -> Result<()> {
let config = Config::load()?;
let agent = config.get_agent(agent_id)?;
let client = HindsightClient::from_agent(agent)?;
let result = client.documents_list()?;
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
+39
View File
@@ -0,0 +1,39 @@
//! Ingest command — upload documents into agent memory.
use anyhow::{Context, Result};
use std::io::Read;
use crate::api::HindsightClient;
use crate::config::Config;
pub fn ingest(
agent_id: &str,
title: &str,
file_path: Option<&str>,
inline_content: Option<&str>,
) -> Result<()> {
let content = if let Some(path) = file_path {
std::fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path))?
} else if let Some(c) = inline_content {
c.to_string()
} else {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("Failed to read from stdin")?;
buf
};
if content.trim().is_empty() {
anyhow::bail!("No content provided. Use --file, --content, or pipe to stdin.");
}
let config = Config::load()?;
let agent = config.get_agent(agent_id)?;
let client = HindsightClient::from_agent(agent)?;
// Use title slug as document_id for upsert
let doc_id = title.to_lowercase().replace(' ', "-");
let result = client.retain(&content, Some(&doc_id))?;
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
+7
View File
@@ -0,0 +1,7 @@
pub mod agents;
pub mod documents;
pub mod ingest;
pub mod recall;
pub mod retain;
pub mod setup;
pub mod wiki;
+19
View File
@@ -0,0 +1,19 @@
//! Recall command — search agent memories.
use anyhow::Result;
use crate::api::HindsightClient;
use crate::config::Config;
pub fn recall(
agent_id: &str,
query: &str,
max_results: u32,
types: &[String],
) -> Result<()> {
let config = Config::load()?;
let agent = config.get_agent(agent_id)?;
let client = HindsightClient::from_agent(agent)?;
let result = client.recall(query, max_results, types)?;
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
+33
View File
@@ -0,0 +1,33 @@
//! Retain command — raw content retention (used by harness plugins).
use anyhow::{Context, Result};
use std::io::Read;
use crate::api::HindsightClient;
use crate::config::Config;
pub fn retain(
agent_id: &str,
input_file: Option<&str>,
document_id: Option<&str>,
) -> Result<()> {
let content = if let Some(path) = input_file {
std::fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path))?
} else {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("Failed to read from stdin")?;
buf
};
if content.trim().is_empty() {
anyhow::bail!("No content to retain.");
}
let config = Config::load()?;
let agent = config.get_agent(agent_id)?;
let client = HindsightClient::from_agent(agent)?;
let result = client.retain(&content, document_id)?;
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
+236
View File
@@ -0,0 +1,236 @@
//! Setup command — one-shot agent onboarding.
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
use std::process::Command;
use crate::api::HindsightClient;
use crate::config::{AgentConfig, Config};
pub fn setup(
agent_id: &str,
bank_id: &str,
api_url: &str,
api_token: Option<&str>,
harness: &str,
template: Option<&str>,
content_dir: Option<&str>,
) -> Result<()> {
eprintln!("Setting up agent '{}'", agent_id);
eprintln!(" Bank: {}", bank_id);
eprintln!(" API: {}", api_url);
eprintln!(" Harness: {}", harness);
eprintln!();
let agent_config = AgentConfig {
bank_id: bank_id.to_string(),
api_url: api_url.to_string(),
api_token: api_token.map(|s| s.to_string()),
harness: harness.to_string(),
workspace: None,
};
let client = HindsightClient::from_agent(&agent_config)?;
// Health check
client
.health()
.context(format!(
"Cannot reach Hindsight at {}. Make sure the server is running.",
api_url
))?;
// Create bank (via template or ensure)
eprintln!("Creating Hindsight bank...");
if let Some(template_path) = template {
let template_str = fs::read_to_string(template_path)
.with_context(|| format!("Failed to read template: {}", template_path))?;
let template_value: serde_json::Value = serde_json::from_str(&template_str)
.with_context(|| format!("Invalid JSON in template: {}", template_path))?;
client.import_template(&template_value)?;
eprintln!(" Template imported.");
} else {
client.ensure_bank()?;
}
eprintln!(" Done.");
// Ingest content directory
if let Some(dir) = content_dir {
ingest_content_dir(&client, dir)?;
}
// Save config
eprintln!("Saving agent config...");
let mut config = Config::load()?;
config
.agents
.insert(agent_id.to_string(), agent_config);
config.save()?;
eprintln!(" Done.");
// Harness-specific setup
match harness {
"hermes" => setup_hermes(agent_id)?,
"openclaw" => setup_openclaw(agent_id)?,
_ => eprintln!(" Unknown harness '{}', skipping harness setup.", harness),
}
eprintln!();
eprintln!("Agent '{}' is ready.", agent_id);
match harness {
"hermes" => {
if agent_id == "default" {
eprintln!(" Start chatting: hermes");
} else {
eprintln!(" Start chatting: hermes --profile {}", agent_id);
}
}
"openclaw" => {
eprintln!(" Restart your openclaw gateway to pick up the new agent.");
}
_ => {}
}
Ok(())
}
const CONTENT_EXTENSIONS: &[&str] = &[".md", ".txt", ".html", ".json", ".csv", ".xml"];
fn ingest_content_dir(client: &HindsightClient, dir: &str) -> Result<()> {
let path = Path::new(dir);
if !path.is_dir() {
anyhow::bail!("Content path is not a directory: {}", dir);
}
let mut entries: Vec<_> = fs::read_dir(path)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path().is_file()
&& e.path()
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| CONTENT_EXTENSIONS.contains(&format!(".{}", ext).as_str()))
.unwrap_or(false)
})
.collect();
entries.sort_by_key(|e| e.path());
if entries.is_empty() {
eprintln!(" No files to ingest in {}", dir);
return Ok(());
}
eprintln!("Ingesting {} file(s) from {}...", entries.len(), dir);
for entry in &entries {
let file_path = entry.path();
let text = fs::read_to_string(&file_path)?;
if text.trim().is_empty() {
continue;
}
let stem = file_path.file_stem().unwrap().to_string_lossy();
let result = client.retain(&text, Some(&stem))?;
let op_id = result["operation_id"]
.as_str()
.unwrap_or("queued");
eprintln!(
" {}{}",
file_path.file_name().unwrap().to_string_lossy(),
op_id,
);
}
eprintln!(" Content ingestion queued (async).");
Ok(())
}
fn setup_hermes(agent_id: &str) -> Result<()> {
eprintln!("Configuring Hermes...");
// Create profile if not default
if agent_id != "default" {
let output = Command::new("hermes")
.args(["profile", "create", agent_id, "--clone", "--no-alias"])
.output();
match output {
Ok(o) if o.status.success() => {
eprintln!(" Created Hermes profile '{}'.", agent_id);
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
if stderr.to_lowercase().contains("already exists") {
eprintln!(" Profile '{}' already exists.", agent_id);
} else {
eprintln!(
" Note: Create profile manually: hermes profile create {}",
agent_id
);
}
}
Err(_) => {
eprintln!(" Note: hermes CLI not found. Create profile manually.");
}
}
}
// Set memory provider
let mut config_cmd = vec!["hermes"];
if agent_id != "default" {
config_cmd.extend(["--profile", agent_id]);
}
config_cmd.extend(["config", "set", "memory.provider", "hindsight_agent"]);
let output = Command::new(config_cmd[0]).args(&config_cmd[1..]).output();
match output {
Ok(o) if o.status.success() => {
eprintln!(" Memory provider set to hindsight_agent.");
}
_ => {
eprintln!(" Note: Set memory provider with: hermes config set memory.provider hindsight_agent");
}
}
eprintln!(" Done.");
Ok(())
}
fn setup_openclaw(agent_id: &str) -> Result<()> {
eprintln!("Configuring OpenClaw...");
// Check if agent exists
let output = Command::new("openclaw")
.args(["agents", "list", "--json"])
.output();
let exists = match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
serde_json::from_str::<serde_json::Value>(&stdout)
.ok()
.and_then(|v| v["agents"].as_array().cloned())
.map(|agents| agents.iter().any(|a| a["name"].as_str() == Some(agent_id)))
.unwrap_or(false)
}
_ => false,
};
if exists {
eprintln!(" Agent '{}' already exists in OpenClaw.", agent_id);
} else {
let output = Command::new("openclaw")
.args(["agents", "add", agent_id, "--non-interactive"])
.output();
match output {
Ok(o) if o.status.success() => {
eprintln!(" Created OpenClaw agent '{}'.", agent_id);
}
_ => {
eprintln!(
" Note: Create agent manually: openclaw agents add {}",
agent_id
);
}
}
}
eprintln!(" Done.");
Ok(())
}
+58
View File
@@ -0,0 +1,58 @@
//! Wiki (knowledge pages) commands.
use anyhow::Result;
use crate::api::HindsightClient;
use crate::config::Config;
pub fn list(agent_id: &str) -> Result<()> {
let config = Config::load()?;
let agent = config.get_agent(agent_id)?;
let client = HindsightClient::from_agent(agent)?;
let result = client.wiki_list()?;
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
pub fn get(agent_id: &str, page_id: &str) -> Result<()> {
let config = Config::load()?;
let agent = config.get_agent(agent_id)?;
let client = HindsightClient::from_agent(agent)?;
let result = client.wiki_get(page_id)?;
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
pub fn create(agent_id: &str, page_id: &str, name: &str, source_query: &str) -> Result<()> {
let config = Config::load()?;
let agent = config.get_agent(agent_id)?;
let client = HindsightClient::from_agent(agent)?;
let result = client.wiki_create(page_id, name, source_query)?;
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
pub fn update(
agent_id: &str,
page_id: &str,
name: Option<&str>,
source_query: Option<&str>,
) -> Result<()> {
if name.is_none() && source_query.is_none() {
anyhow::bail!("At least one of --name or --source-query must be provided");
}
let config = Config::load()?;
let agent = config.get_agent(agent_id)?;
let client = HindsightClient::from_agent(agent)?;
let result = client.wiki_update(page_id, name, source_query)?;
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
pub fn delete(agent_id: &str, page_id: &str) -> Result<()> {
let config = Config::load()?;
let agent = config.get_agent(agent_id)?;
let client = HindsightClient::from_agent(agent)?;
client.wiki_delete(page_id)?;
println!("{{\"success\": true}}");
Ok(())
}
+122
View File
@@ -0,0 +1,122 @@
//! Agent configuration.
//!
//! Stores agent → Hindsight mapping in ~/.hindsight-agent/config.json.
//! Each agent has a bank_id, api_url, optional api_token, and harness.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub bank_id: String,
pub api_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_token: Option<String>,
pub harness: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
pub agents: HashMap<String, AgentConfig>,
}
impl Config {
pub fn load() -> Result<Self> {
let path = config_path();
if !path.exists() {
return Ok(Config {
agents: HashMap::new(),
});
}
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config at {}", path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("Failed to parse config at {}", path.display()))
}
pub fn save(&self) -> Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
fs::write(&path, format!("{}\n", content))?;
Ok(())
}
pub fn get_agent(&self, agent_id: &str) -> Result<&AgentConfig> {
self.agents.get(agent_id).ok_or_else(|| {
anyhow::anyhow!(
"Agent '{}' not found. Run 'hindsight-agent setup {}' first.\n\
Available agents: {}",
agent_id,
agent_id,
if self.agents.is_empty() {
"(none)".to_string()
} else {
self.agents.keys().cloned().collect::<Vec<_>>().join(", ")
}
)
})
}
}
pub fn config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".hindsight-agent")
.join("config.json")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_roundtrip() {
let mut config = Config {
agents: HashMap::new(),
};
config.agents.insert(
"test-agent".to_string(),
AgentConfig {
bank_id: "test-bank".to_string(),
api_url: "http://localhost:8888".to_string(),
api_token: None,
harness: "hermes".to_string(),
workspace: None,
},
);
let json = serde_json::to_string_pretty(&config).unwrap();
let parsed: Config = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.agents.len(), 1);
assert_eq!(parsed.agents["test-agent"].bank_id, "test-bank");
}
#[test]
fn test_get_agent_not_found() {
let config = Config {
agents: HashMap::new(),
};
let result = config.get_agent("missing");
assert!(result.is_err());
}
#[test]
fn test_api_token_omitted_when_none() {
let agent = AgentConfig {
bank_id: "b".to_string(),
api_url: "http://localhost".to_string(),
api_token: None,
harness: "hermes".to_string(),
workspace: None,
};
let json = serde_json::to_string(&agent).unwrap();
assert!(!json.contains("api_token"));
}
}
+257
View File
@@ -0,0 +1,257 @@
mod api;
mod commands;
mod config;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "hindsight-agent")]
#[command(about = "Agent CLI for Hindsight Wiki — self-learning knowledge pages for AI agents")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Set up a new agent with Hindsight memory
Setup {
/// Agent identifier (e.g., your Hermes profile name or OpenClaw agent name)
agent_id: String,
/// Hindsight bank ID for this agent
#[arg(long)]
bank_id: String,
/// Hindsight API URL
#[arg(long, default_value = "http://localhost:8888", env = "HINDSIGHT_API_URL")]
api_url: String,
/// Hindsight API token (for cloud/authenticated instances)
#[arg(long, env = "HINDSIGHT_API_TOKEN")]
api_token: Option<String>,
/// Agent harness
#[arg(long, value_parser = ["hermes", "openclaw"])]
harness: String,
/// Bank template JSON file to import
#[arg(long)]
template: Option<String>,
/// Directory of files to ingest at setup time
#[arg(long)]
content: Option<String>,
},
/// Manage configured agents
Agents {
#[command(subcommand)]
command: AgentsCommands,
},
/// Manage wiki pages (knowledge that evolves from conversations)
Wiki {
#[command(subcommand)]
command: WikiCommands,
},
/// Search agent memories
Recall {
/// Agent identifier
agent_id: String,
/// Search query
query: String,
/// Maximum results to return
#[arg(short = 'n', long, default_value = "10")]
max_results: u32,
/// Filter by fact type (repeatable: observation, world, experience)
#[arg(long = "type")]
types: Vec<String>,
},
/// Ingest a document into agent memory
Ingest {
/// Agent identifier
agent_id: String,
/// Document title (used as document ID for upsert)
title: String,
/// Read content from a file
#[arg(short = 'f', long = "file")]
file_path: Option<String>,
/// Inline content string
#[arg(short = 'c', long = "content")]
inline_content: Option<String>,
},
/// List documents retained for an agent
Documents {
/// Agent identifier
agent_id: String,
},
/// Retain raw content (used by harness plugins)
Retain {
/// Agent identifier
agent_id: String,
/// Read content from a file (reads stdin if omitted)
#[arg(long)]
input: Option<String>,
/// Document ID for upsert behavior
#[arg(long)]
document_id: Option<String>,
},
}
#[derive(Subcommand)]
enum AgentsCommands {
/// List all configured agents
List,
/// Show details for a specific agent
Show {
/// Agent identifier
agent_id: String,
},
}
#[derive(Subcommand)]
enum WikiCommands {
/// List all wiki pages
List {
/// Agent identifier
agent_id: String,
},
/// Get a specific wiki page
Get {
/// Agent identifier
agent_id: String,
/// Page identifier
page_id: String,
},
/// Create a new wiki page
Create {
/// Agent identifier
agent_id: String,
/// Page identifier (lowercase with hyphens)
page_id: String,
/// Page name
name: String,
/// Synthesis query — the question the system re-asks to rebuild this page
source_query: String,
},
/// Update a wiki page
Update {
/// Agent identifier
agent_id: String,
/// Page identifier
page_id: String,
/// New page name
#[arg(long)]
name: Option<String>,
/// New synthesis query
#[arg(long)]
source_query: Option<String>,
},
/// Delete a wiki page
Delete {
/// Agent identifier
agent_id: String,
/// Page identifier
page_id: String,
},
}
fn main() {
let cli = Cli::parse();
let result = match cli.command {
Commands::Setup {
agent_id,
bank_id,
api_url,
api_token,
harness,
template,
content,
} => commands::setup::setup(
&agent_id,
&bank_id,
&api_url,
api_token.as_deref(),
&harness,
template.as_deref(),
content.as_deref(),
),
Commands::Agents { command } => match command {
AgentsCommands::List => commands::agents::list(),
AgentsCommands::Show { agent_id } => commands::agents::show(&agent_id),
},
Commands::Wiki { command } => match command {
WikiCommands::List { agent_id } => commands::wiki::list(&agent_id),
WikiCommands::Get { agent_id, page_id } => commands::wiki::get(&agent_id, &page_id),
WikiCommands::Create {
agent_id,
page_id,
name,
source_query,
} => commands::wiki::create(&agent_id, &page_id, &name, &source_query),
WikiCommands::Update {
agent_id,
page_id,
name,
source_query,
} => commands::wiki::update(
&agent_id,
&page_id,
name.as_deref(),
source_query.as_deref(),
),
WikiCommands::Delete { agent_id, page_id } => {
commands::wiki::delete(&agent_id, &page_id)
}
},
Commands::Recall {
agent_id,
query,
max_results,
types,
} => commands::recall::recall(&agent_id, &query, max_results, &types),
Commands::Ingest {
agent_id,
title,
file_path,
inline_content,
} => commands::ingest::ingest(
&agent_id,
&title,
file_path.as_deref(),
inline_content.as_deref(),
),
Commands::Documents { agent_id } => commands::documents::list(&agent_id),
Commands::Retain {
agent_id,
input,
document_id,
} => commands::retain::retain(&agent_id, input.as_deref(), document_id.as_deref()),
};
if let Err(e) = result {
eprintln!("Error: {:#}", e);
std::process::exit(1);
}
}
+177
View File
@@ -0,0 +1,177 @@
//! Integration tests for the hindsight-agent CLI.
//!
//! Tests that exercise the binary via subprocess, verifying command parsing,
//! config file handling, and error messages.
use std::process::Command;
fn agent_bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_hindsight-agent"))
}
// ── Help & version ──────────────────────────────────────
#[test]
fn test_help() {
let output = agent_bin().arg("--help").output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(stdout.contains("Agent CLI for Hindsight Wiki"));
assert!(stdout.contains("setup"));
assert!(stdout.contains("wiki"));
assert!(stdout.contains("recall"));
assert!(stdout.contains("ingest"));
assert!(stdout.contains("documents"));
assert!(stdout.contains("agents"));
assert!(stdout.contains("retain"));
}
#[test]
fn test_version() {
let output = agent_bin().arg("--version").output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(stdout.contains("hindsight-agent"));
}
// ── Wiki subcommand help ────────────────────────────────
#[test]
fn test_wiki_help() {
let output = agent_bin().args(["wiki", "--help"]).output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(stdout.contains("list"));
assert!(stdout.contains("get"));
assert!(stdout.contains("create"));
assert!(stdout.contains("update"));
assert!(stdout.contains("delete"));
}
// ── Agents subcommand ───────────────────────────────────
#[test]
fn test_agents_help() {
let output = agent_bin().args(["agents", "--help"]).output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(stdout.contains("list"));
assert!(stdout.contains("show"));
}
#[test]
fn test_agents_list_reads_config() {
// This reads the real ~/.hindsight-agent/config.json
// Should succeed even if empty
let output = agent_bin().args(["agents", "list"]).output().unwrap();
assert!(output.status.success());
}
// ── Error handling ──────────────────────────────────────
#[test]
fn test_wiki_list_unknown_agent() {
let output = agent_bin()
.args(["wiki", "list", "nonexistent-agent-xyz-123"])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("not found"));
}
#[test]
fn test_recall_unknown_agent() {
let output = agent_bin()
.args(["recall", "nonexistent-agent-xyz-123", "test query"])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("not found"));
}
#[test]
fn test_ingest_unknown_agent() {
let output = agent_bin()
.args(["ingest", "nonexistent-agent-xyz-123", "title", "-c", "content"])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("not found"));
}
#[test]
fn test_documents_unknown_agent() {
let output = agent_bin()
.args(["documents", "nonexistent-agent-xyz-123"])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("not found"));
}
// ── Setup validation ────────────────────────────────────
#[test]
fn test_setup_requires_bank_id() {
let output = agent_bin()
.args(["setup", "test-agent", "--harness", "hermes"])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("--bank-id"));
}
#[test]
fn test_setup_requires_harness() {
let output = agent_bin()
.args(["setup", "test-agent", "--bank-id", "test"])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("--harness"));
}
#[test]
fn test_setup_rejects_invalid_harness() {
let output = agent_bin()
.args([
"setup",
"test-agent",
"--bank-id",
"test",
"--harness",
"invalid",
])
.output()
.unwrap();
assert!(!output.status.success());
}
// ── Wiki create validation ──────────────────────────────
#[test]
fn test_wiki_create_requires_all_args() {
// Missing source_query
let output = agent_bin()
.args(["wiki", "create", "agent", "page-id", "Name"])
.output()
.unwrap();
assert!(!output.status.success());
}
#[test]
fn test_wiki_update_requires_flag() {
let output = agent_bin()
.args(["wiki", "update", "nonexistent-xyz", "page-id"])
.output()
.unwrap();
// Should fail because no --name or --source-query provided
// (may fail on agent lookup first)
assert!(!output.status.success());
}
+226
View File
@@ -0,0 +1,226 @@
# hindsight-agent
Agent scaffolding and runtime CLI for [Hindsight](https://github.com/vectorize-io/hindsight) memory. One command sets up an agent with long-term memory. The CLI handles all Hindsight internals — the agent just uses an agent ID.
## Install
```bash
cd hindsight-agent
uv tool install -e .
```
## Connecting to Hindsight
The CLI supports local, self-hosted, and cloud Hindsight instances.
**Local (default):**
```bash
hindsight-agent setup my-agent --bank-id my-bank --harness openclaw
# Uses http://localhost:8888, no auth
```
**Self-hosted:**
```bash
hindsight-agent setup my-agent --bank-id my-bank --harness openclaw \
--api-url https://hindsight.internal.company.com
```
**Cloud (Hindsight Cloud):**
```bash
hindsight-agent setup my-agent --bank-id my-bank --harness openclaw \
--api-url https://api.hindsight.cloud \
--api-token hst_your_api_token_here
```
You can also set these via environment variables:
```bash
export HINDSIGHT_API_URL=https://api.hindsight.cloud
export HINDSIGHT_API_TOKEN=hst_your_api_token_here
hindsight-agent setup my-agent --bank-id my-bank --harness openclaw
```
The API URL and token are stored per-agent in `~/.hindsight-agent/config.json`. All subsequent commands read from this config — no need to pass them again.
## Commands
### `setup` — One-shot agent onboarding
Creates the Hindsight bank, installs the agent-knowledge skill, configures the harness, and optionally imports a template and ingests reference docs.
```bash
hindsight-agent setup <agent-id> \
--bank-id <bank-id> \
--harness openclaw \
[--api-url <url>] \
[--api-token <token>] \
[--template <path/to/template.json>] \
[--content <path/to/content-dir/>] \
[--workspace <path>] \
[--model <model-id>]
```
| Option | Description |
|--------|-------------|
| `--bank-id` | Hindsight bank ID for this agent (required) |
| `--harness` | Agent harness — `openclaw` (required) |
| `--api-url` | Hindsight API URL (default: `http://localhost:8888`, env: `HINDSIGHT_API_URL`) |
| `--api-token` | API token for authenticated instances (env: `HINDSIGHT_API_TOKEN`) |
| `--template` | Bank template JSON — pre-configures missions, pages, directives |
| `--content` | Directory of files (.md, .txt, .html, .json, .csv, .xml) to ingest at setup |
| `--workspace` | Agent workspace directory (default: `~/.hindsight-agents/openclaw/<agent-id>`) |
| `--model` | LLM model ID for the harness agent |
What setup does:
1. Creates the Hindsight bank (or imports template which creates it)
2. Ingests reference docs from `--content` directory (async)
3. Saves agent config to `~/.hindsight-agent/config.json`
4. Installs the `agent-knowledge` skill into the workspace
5. Patches `AGENTS.md` to load the skill at session startup
6. Creates the harness agent and registers the retain plugin
### `pages` — Manage knowledge pages
Knowledge pages are mental models that the system keeps updated from conversations. The agent creates them; the system refreshes them after each consolidation.
```bash
# List all pages
hindsight-agent pages list <agent-id>
# Get a specific page
hindsight-agent pages get <agent-id> <page-id>
# Create a new page
hindsight-agent pages create <agent-id> "<name>" "<source-query>" [--id <page-id>]
# Update a page
hindsight-agent pages update <agent-id> <page-id> [--name "..."] [--source-query "..."]
# Delete a page
hindsight-agent pages delete <agent-id> <page-id>
```
The `source_query` is the key field — it's a question the system re-asks after every consolidation to rebuild the page content from accumulated observations.
### `recall` — Search memories
Query across all retained knowledge — conversations, reference documents, observations.
```bash
# Search memories
hindsight-agent recall <agent-id> "<query>"
# Limit results
hindsight-agent recall <agent-id> "<query>" -n 5
# Filter by fact type
hindsight-agent recall <agent-id> "<query>" --type observation
hindsight-agent recall <agent-id> "<query>" --type world --type experience
```
### `documents` — List retained documents
See what reference content and conversation transcripts have been retained.
```bash
hindsight-agent documents <agent-id>
```
### `retain` — Retain content
Pipe content into an agent's memory bank. Used by the OpenClaw plugin; can also be called directly.
```bash
# From stdin
echo "user preferences and feedback" | hindsight-agent retain <agent-id>
# From file
hindsight-agent retain <agent-id> --input conversation.txt
# With document ID (for upsert)
echo "updated content" | hindsight-agent retain <agent-id> --document-id session-123
```
Content is always retained asynchronously.
## Config
Agent configs are stored at `~/.hindsight-agent/config.json`:
```json
{
"agents": {
"my-agent": {
"bank_id": "my-bank",
"api_url": "http://localhost:8888",
"api_token": "hst_...",
"harness": "openclaw",
"workspace": "/Users/me/.hindsight-agents/openclaw/my-agent"
}
}
}
```
All commands resolve the agent ID to bank + API URL + token from this file.
## OpenClaw Plugin
The setup command registers a lightweight retain plugin in OpenClaw. On every `agent_end`, the plugin:
1. Reads `~/.hindsight-agent/config.json` to resolve bank + API URL + token
2. Filters messages to user/assistant text only (no tool calls)
3. POSTs to the Hindsight retain API (async)
If an agent isn't in the config, the plugin silently skips it — so it doesn't interfere with other agents.
## Hermes Plugin
For Hermes agents, setup installs a memory provider plugin at `~/.hermes/plugins/hindsight-agent/`. It implements the `MemoryProvider` ABC:
- **`sync_turn`**: Buffers user/assistant turns during the session
- **`on_session_end`**: Retains the full session to Hindsight (async HTTP POST)
- **No tools, no prefetch**: The agent-knowledge skill handles reads via the CLI
After setup, activate with:
```bash
hermes config set memory.provider hindsight-agent
```
The plugin reads `~/.hindsight-agent/config.json` for bank/URL/token resolution — same config as the CLI and the OpenClaw plugin.
## Bank Templates
Templates pre-configure a bank with missions, mental models, and directives:
```json
{
"version": "1",
"bank": {
"reflect_mission": "...",
"retain_mission": "...",
"enable_observations": true
},
"mental_models": [
{
"id": "preferences",
"name": "User Preferences",
"source_query": "What are the user's preferences...?",
"max_tokens": 4096,
"trigger": {
"refresh_after_consolidation": true,
"mode": "delta",
"exclude_mental_models": true,
"fact_types": ["observation"]
}
}
],
"directives": [
{
"name": "Rule name",
"content": "Rule content",
"priority": 10
}
]
}
```
See the [Hindsight docs](https://docs.hindsight.cloud/developer/api/bank-templates) for the full template schema.
+161
View File
@@ -0,0 +1,161 @@
"""Thin Hindsight API client for hindsight-agent."""
from __future__ import annotations
import httpx
class HindsightAPI:
def __init__(self, api_url: str, api_token: str | None = None, timeout: float = 30.0):
self.base = api_url.rstrip("/")
headers = {}
if api_token:
headers["Authorization"] = f"Bearer {api_token}"
self.client = httpx.Client(base_url=self.base, timeout=timeout, headers=headers)
def _bank_url(self, bank_id: str) -> str:
return f"/v1/default/banks/{bank_id}"
# ── Bank ──────────────────────────────────────────────
def ensure_bank(self, bank_id: str) -> None:
"""Ensure bank exists by checking the banks list, creating via empty retain if needed."""
r = self.client.get("/v1/default/banks")
if r.status_code == 200:
for bank in r.json().get("banks", []):
if bank.get("bank_id") == bank_id:
return
# Bank doesn't exist — create it with a no-op retain (empty items list triggers bank creation)
r = self.client.post(
f"{self._bank_url(bank_id)}/memories",
json={"items": []},
)
# If empty items isn't allowed, the bank should already exist from the GET check
if r.status_code not in (200, 201, 422):
r.raise_for_status()
# ── Retain ────────────────────────────────────────────
def retain(
self, bank_id: str, content: str, *, document_id: str | None = None
) -> dict:
"""Retain content into a bank (always async)."""
item: dict = {"content": content}
if document_id:
item["document_id"] = document_id
payload: dict = {"items": [item], "async": True}
r = self.client.post(
f"{self._bank_url(bank_id)}/memories",
json=payload,
)
r.raise_for_status()
return r.json()
# ── Mental Models (pages) ─────────────────────────────
def list_pages(self, bank_id: str) -> list[dict]:
r = self.client.get(
f"{self._bank_url(bank_id)}/mental-models",
)
r.raise_for_status()
return r.json().get("items", [])
def get_page(self, bank_id: str, page_id: str) -> dict:
r = self.client.get(
f"{self._bank_url(bank_id)}/mental-models/{page_id}",
)
r.raise_for_status()
return r.json()
def create_page(
self,
bank_id: str,
*,
name: str,
source_query: str,
page_id: str | None = None,
) -> dict:
payload: dict = {
"name": name,
"source_query": source_query,
"trigger": {
"mode": "delta",
"refresh_after_consolidation": True,
"exclude_mental_models": True,
"fact_types": ["observation"],
},
}
if page_id:
payload["id"] = page_id
r = self.client.post(
f"{self._bank_url(bank_id)}/mental-models",
json=payload,
)
r.raise_for_status()
return r.json()
def update_page(
self,
bank_id: str,
page_id: str,
*,
name: str | None = None,
source_query: str | None = None,
) -> dict:
payload: dict = {}
if name is not None:
payload["name"] = name
if source_query is not None:
payload["source_query"] = source_query
r = self.client.patch(
f"{self._bank_url(bank_id)}/mental-models/{page_id}",
json=payload,
)
r.raise_for_status()
return r.json()
def delete_page(self, bank_id: str, page_id: str) -> None:
r = self.client.delete(
f"{self._bank_url(bank_id)}/mental-models/{page_id}",
)
r.raise_for_status()
# ── Recall ────────────────────────────────────────────
def recall(
self,
bank_id: str,
query: str,
*,
max_results: int = 10,
types: list[str] | None = None,
) -> dict:
"""Recall memories from a bank."""
payload: dict = {"query": query, "max_results": max_results}
if types:
payload["types"] = types
r = self.client.post(
f"{self._bank_url(bank_id)}/memories/recall",
json=payload,
)
r.raise_for_status()
return r.json()
# ── Documents ─────────────────────────────────────────
def list_documents(self, bank_id: str) -> list[dict]:
"""List documents in a bank."""
r = self.client.get(f"{self._bank_url(bank_id)}/documents")
r.raise_for_status()
return r.json().get("documents", r.json().get("items", []))
# ── Bank Template ─────────────────────────────────────
def import_template(self, bank_id: str, template: dict) -> dict:
"""Import a bank template (disposition, mission, directives, etc.)."""
r = self.client.post(
f"{self._bank_url(bank_id)}/import",
json=template,
)
r.raise_for_status()
return r.json()
+31
View File
@@ -0,0 +1,31 @@
"""hindsight-agent CLI — agent scaffolding and runtime for Hindsight memory."""
from __future__ import annotations
import click
from .commands.documents import documents
from .commands.ingest import ingest
from .commands.list_agents import list_agents
from .commands.pages import pages
from .commands.recall import recall
from .commands.retain import retain
from .commands.setup import setup
@click.group()
def main() -> None:
"""Agent scaffolding and runtime CLI for Hindsight memory."""
main.add_command(setup)
main.add_command(list_agents)
main.add_command(retain)
main.add_command(ingest)
main.add_command(pages)
main.add_command(recall)
main.add_command(documents)
if __name__ == "__main__":
main()
@@ -0,0 +1,24 @@
"""hindsight-agent documents — list documents retained for an agent."""
from __future__ import annotations
import json
import click
from ..api import HindsightAPI
from ..config import get_agent
@click.command("documents")
@click.argument("agent_id")
def documents(agent_id: str) -> None:
"""List documents retained for an agent.
Shows reference documents, conversation transcripts, and other
content that has been retained into the agent's memory bank.
"""
cfg = get_agent(agent_id)
api = HindsightAPI(cfg.api_url, api_token=cfg.api_token)
docs = api.list_documents(cfg.bank_id)
click.echo(json.dumps({"documents": docs}, indent=2))
@@ -0,0 +1,50 @@
"""hindsight-agent ingest — upload a resource directly into an agent's memory."""
from __future__ import annotations
import json
import sys
import click
from ..api import HindsightAPI
from ..config import get_agent
@click.command("ingest-document")
@click.argument("agent_id")
@click.argument("title")
@click.option("--file", "-f", "file_path", type=click.Path(exists=True), default=None, help="Read content from a file")
@click.option("--content", "-c", "inline_content", default=None, help="Inline content string")
def ingest(agent_id: str, title: str, file_path: str | None, inline_content: str | None) -> None:
"""Ingest a resource into an agent's memory.
AGENT_ID identifies which agent's bank to retain into.
TITLE is used as the document ID for upsert behavior.
Content is read from --file, --content, or stdin.
Examples:
hindsight-agent ingest my-agent "SEO Best Practices" -f seo-guide.md
hindsight-agent ingest my-agent "Style Guide" -c "Always use active voice..."
cat notes.txt | hindsight-agent ingest my-agent "Meeting Notes"
"""
if file_path:
with open(file_path) as f:
content = f.read()
elif inline_content:
content = inline_content
else:
content = sys.stdin.read()
if not content.strip():
raise click.ClickException("No content provided. Use --file, --content, or pipe to stdin.")
cfg = get_agent(agent_id)
api = HindsightAPI(cfg.api_url, api_token=cfg.api_token)
# Use title as document_id (slug) for upsert
doc_id = title.lower().replace(" ", "-")
result = api.retain(cfg.bank_id, content, document_id=doc_id)
click.echo(json.dumps(result))
@@ -0,0 +1,22 @@
"""hindsight-agent list — show all configured agents."""
from __future__ import annotations
import json
import click
from ..config import load_config
@click.command("list")
def list_agents() -> None:
"""List all configured agents and their settings."""
agents = load_config()
if not agents:
click.echo("No agents configured. Run 'hindsight-agent setup' to add one.")
return
click.echo(json.dumps(
{aid: cfg.to_dict() for aid, cfg in agents.items()},
indent=2,
))
@@ -0,0 +1,91 @@
"""hindsight-agent pages — manage knowledge pages for an agent.
All commands resolve agent_id → bank via the global config,
so the caller never needs to know Hindsight internals.
"""
from __future__ import annotations
import json
import click
from ..api import HindsightAPI
from ..config import get_agent
@click.group()
def pages() -> None:
"""Manage knowledge pages for an agent."""
@pages.command("list")
@click.argument("agent_id")
def list_pages(agent_id: str) -> None:
"""List all knowledge pages for an agent."""
cfg = get_agent(agent_id)
api = HindsightAPI(cfg.api_url, api_token=cfg.api_token)
items = api.list_pages(cfg.bank_id)
click.echo(json.dumps({"items": items}, indent=2))
@pages.command("get")
@click.argument("agent_id")
@click.argument("page_id")
def get_page(agent_id: str, page_id: str) -> None:
"""Get a specific knowledge page."""
cfg = get_agent(agent_id)
api = HindsightAPI(cfg.api_url, api_token=cfg.api_token)
page = api.get_page(cfg.bank_id, page_id)
click.echo(json.dumps(page, indent=2))
@pages.command("create")
@click.argument("agent_id")
@click.argument("page_id")
@click.argument("name")
@click.argument("source_query")
def create_page(agent_id: str, page_id: str, name: str, source_query: str) -> None:
"""Create a new knowledge page.
NAME is the page title.
SOURCE_QUERY is the question the system re-asks on every consolidation
to rebuild the page content from observations.
"""
cfg = get_agent(agent_id)
api = HindsightAPI(cfg.api_url, api_token=cfg.api_token)
result = api.create_page(
cfg.bank_id,
name=name,
source_query=source_query,
page_id=page_id,
)
click.echo(json.dumps(result, indent=2))
@pages.command("update")
@click.argument("agent_id")
@click.argument("page_id")
@click.option("--name", default=None, help="New page name")
@click.option("--source-query", default=None, help="New source query")
def update_page(
agent_id: str, page_id: str, name: str | None, source_query: str | None
) -> None:
"""Update a knowledge page's name or source query."""
if name is None and source_query is None:
raise click.ClickException("At least one of --name or --source-query must be provided.")
cfg = get_agent(agent_id)
api = HindsightAPI(cfg.api_url, api_token=cfg.api_token)
result = api.update_page(cfg.bank_id, page_id, name=name, source_query=source_query)
click.echo(json.dumps(result, indent=2))
@pages.command("delete")
@click.argument("agent_id")
@click.argument("page_id")
def delete_page(agent_id: str, page_id: str) -> None:
"""Delete a knowledge page."""
cfg = get_agent(agent_id)
api = HindsightAPI(cfg.api_url, api_token=cfg.api_token)
api.delete_page(cfg.bank_id, page_id)
click.echo(json.dumps({"success": True}))
@@ -0,0 +1,35 @@
"""hindsight-agent recall — query memories for an agent."""
from __future__ import annotations
import json
import click
from ..api import HindsightAPI
from ..config import get_agent
@click.command()
@click.argument("agent_id")
@click.argument("query")
@click.option("--max-results", "-n", default=10, help="Maximum results to return")
@click.option(
"--type", "types", multiple=True,
help="Filter by fact type (world, experience, observation). Repeatable.",
)
def recall(agent_id: str, query: str, max_results: int, types: tuple[str, ...]) -> None:
"""Recall memories for an agent.
AGENT_ID identifies which agent's bank to query.
QUERY is the natural language search query.
"""
cfg = get_agent(agent_id)
api = HindsightAPI(cfg.api_url, api_token=cfg.api_token)
result = api.recall(
cfg.bank_id,
query,
max_results=max_results,
types=list(types) if types else None,
)
click.echo(json.dumps(result, indent=2))
@@ -0,0 +1,50 @@
"""hindsight-agent retain — called by harness plugins to retain conversation content."""
from __future__ import annotations
import json
import sys
import click
from ..api import HindsightAPI
from ..config import get_agent
@click.command()
@click.argument("agent_id")
@click.option(
"--input",
"input_file",
type=click.Path(exists=True),
default=None,
help="Path to file with content to retain. Reads stdin if omitted.",
)
@click.option(
"--document-id",
default=None,
help="Document ID for upsert behavior.",
)
def retain(agent_id: str, input_file: str | None, document_id: str | None) -> None:
"""Retain content for an agent.
AGENT_ID identifies which agent's bank to retain into.
Content is read as-is from --input or stdin and passed directly to Hindsight.
The caller (plugin) decides the format.
"""
cfg = get_agent(agent_id)
api = HindsightAPI(cfg.api_url, api_token=cfg.api_token)
# Read content as raw text
if input_file:
with open(input_file) as f:
content = f.read()
else:
content = sys.stdin.read()
if not content.strip():
click.echo("No content to retain.", err=True)
return
result = api.retain(cfg.bank_id, content, document_id=document_id)
click.echo(json.dumps(result))
@@ -0,0 +1,373 @@
"""hindsight-agent setup — one-shot agent onboarding.
Creates the Hindsight bank, installs the agent-knowledge skill
(with the agent ID baked in), and does harness-specific setup.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import click
from ..api import HindsightAPI
from ..config import AgentConfig, load_config, save_config
SKILL_TEMPLATE_DIR = Path(__file__).resolve().parent.parent.parent / "skill"
OPENCLAW_PLUGIN_DIR = Path(__file__).resolve().parent.parent.parent / "plugin" / "openclaw"
@click.command()
@click.argument("agent_id")
@click.option("--bank-id", required=True, help="Hindsight bank ID for this agent")
@click.option(
"--api-url",
default="http://localhost:8888",
show_default=True,
envvar="HINDSIGHT_API_URL",
help="Hindsight API URL",
)
@click.option(
"--api-token",
default=None,
envvar="HINDSIGHT_API_TOKEN",
help="Hindsight API token (for cloud/authenticated instances)",
)
@click.option(
"--harness",
type=click.Choice(["openclaw", "hermes"]),
required=True,
help="Agent harness to configure",
)
@click.option("--workspace", type=click.Path(), default=None, help="Agent workspace directory (harness-specific default if omitted)")
@click.option("--model", default=None, help="LLM model ID for the agent (harness-specific)")
@click.option("--template", type=click.Path(exists=True), default=None, help="Bank template JSON file to import after bank creation")
@click.option("--content", type=click.Path(exists=True), default=None, help="Directory of files to ingest into the bank at setup time (async)")
def setup(
agent_id: str,
bank_id: str,
api_url: str,
api_token: str | None,
harness: str,
workspace: str | None,
model: str | None,
template: str | None,
content: str | None,
) -> None:
"""Set up a new agent with Hindsight memory.
AGENT_ID is the unique identifier for this agent.
"""
workspace_path = _resolve_workspace(agent_id, harness, workspace)
click.echo(f"Setting up agent '{agent_id}'")
click.echo(f" Bank: {bank_id}")
click.echo(f" API: {api_url}")
click.echo(f" Harness: {harness}")
click.echo(f" Workspace: {workspace_path}")
click.echo()
# 0. Health check
api = HindsightAPI(api_url, api_token=api_token)
try:
r = api.client.get("/health")
r.raise_for_status()
except Exception:
raise click.ClickException(
f"Cannot reach Hindsight at {api_url}. "
"Make sure the server is running or pass --api-url / --api-token."
)
# 1. Create bank on Hindsight
click.echo("Creating Hindsight bank...")
# If template provided, import it first (this also creates the bank)
if template:
click.echo(f" Importing bank template from {template}...")
template_data = json.loads(Path(template).read_text())
api.import_template(bank_id, template_data)
else:
api.ensure_bank(bank_id)
click.echo(" Done.")
# 2. Ingest content directory if provided
if content:
_ingest_content(api, bank_id, Path(content).expanduser().resolve())
# 3. Save to global config
click.echo("Saving agent config...")
agents = load_config()
agents[agent_id] = AgentConfig(
bank_id=bank_id,
api_url=api_url,
harness=harness,
workspace=str(workspace_path),
api_token=api_token,
)
save_config(agents)
click.echo(" Done.")
# 4. Install skill into workspace
click.echo("Installing agent-knowledge skill...")
_install_skill(agent_id, workspace_path, harness)
click.echo(" Done.")
# 5. Harness-specific setup
if harness == "openclaw":
click.echo("Configuring OpenClaw...")
_setup_openclaw(agent_id, workspace_path, model)
click.echo(" Done.")
elif harness == "hermes":
click.echo("Configuring Hermes...")
_setup_hermes(agent_id, workspace_path)
click.echo(" Done.")
click.echo()
click.echo(f"Agent '{agent_id}' is ready.")
if harness == "openclaw":
click.echo(" Restart your openclaw gateway to pick up the new agent.")
elif harness == "hermes":
if agent_id == "default":
click.echo(" Start chatting: hermes")
else:
click.echo(f" Start chatting: hermes --profile {agent_id}")
CONTENT_EXTENSIONS = {".md", ".txt", ".html", ".json", ".csv", ".xml"}
def _ingest_content(api: HindsightAPI, bank_id: str, content_dir: Path) -> None:
"""Ingest all files from a directory into the bank (async)."""
if not content_dir.is_dir():
raise click.ClickException(f"Content path is not a directory: {content_dir}")
files = [
f for f in sorted(content_dir.iterdir())
if f.is_file() and f.suffix.lower() in CONTENT_EXTENSIONS
]
if not files:
click.echo(f" No files to ingest in {content_dir}")
return
click.echo(f"Ingesting {len(files)} file(s) from {content_dir}...")
for f in files:
text = f.read_text(errors="replace")
if not text.strip():
continue
result = api.retain(bank_id, text, document_id=f.stem)
op_id = result.get("operation_id", "")
click.echo(f" {f.name} → queued (operation: {op_id})")
click.echo(" Content ingestion queued (async). Run consolidation after completion.")
HERMES_PLUGIN_DIR = Path(__file__).resolve().parent.parent.parent / "plugin" / "hermes"
def _resolve_workspace(agent_id: str, harness: str, workspace: str | None) -> Path:
if workspace:
return Path(workspace).expanduser().resolve()
if harness == "openclaw":
return Path.home() / ".hindsight-agents" / "openclaw" / agent_id
if harness == "hermes":
# Hermes skills live in ~/.hermes/skills/
return Path.home() / ".hermes"
return Path.home() / ".hindsight-agents" / agent_id
def _install_skill(agent_id: str, workspace: Path, harness: str) -> None:
"""Copy the skill template into the workspace with agent_id baked in."""
skill_dir = workspace / "skills" / "agent-knowledge"
skill_dir.mkdir(parents=True, exist_ok=True)
template = SKILL_TEMPLATE_DIR / "SKILL.md"
if not template.exists():
raise click.ClickException(f"Skill template not found at {template}")
content = template.read_text()
content = content.replace("{{AGENT_ID}}", agent_id)
(skill_dir / "SKILL.md").write_text(content)
# Harness-specific: patch startup to auto-load the skill
if harness == "openclaw":
agents_md = workspace / "AGENTS.md"
if agents_md.exists():
text = agents_md.read_text()
skill_line = "5. Read `skills/agent-knowledge/SKILL.md` and **execute its mandatory startup sequence** (run the commands, don't just read them)"
if "agent-knowledge/SKILL.md" not in text and "## Session Startup" in text:
text = text.replace(
"Don't ask permission. Just do it.",
f"{skill_line}\n\nDon't ask permission. Just do it.",
)
agents_md.write_text(text)
def _setup_openclaw(agent_id: str, workspace: Path, model: str | None) -> None:
"""Create the OpenClaw agent and install the retain plugin."""
# Ensure workspace exists with basic structure
workspace.mkdir(parents=True, exist_ok=True)
# Check if agent already exists in OpenClaw
if _openclaw_agent_exists(agent_id):
click.echo(f" Agent '{agent_id}' already exists in OpenClaw, skipping creation.")
else:
# Create the agent
cmd = [
"openclaw", "agents", "add", agent_id,
"--workspace", str(workspace),
"--non-interactive",
]
if model:
cmd.extend(["--model", model])
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise click.ClickException(f"Failed to create OpenClaw agent: {result.stderr}")
click.echo(f" Created OpenClaw agent '{agent_id}'.")
# Install the retain plugin
_install_openclaw_plugin(agent_id)
def _openclaw_agent_exists(agent_id: str) -> bool:
"""Check if an agent exists in OpenClaw."""
result = subprocess.run(
["openclaw", "agents", "list", "--json"],
capture_output=True, text=True,
)
if result.returncode != 0:
return False
try:
data = json.loads(result.stdout)
return any(a.get("name") == agent_id for a in data.get("agents", []))
except (json.JSONDecodeError, KeyError):
return False
def _install_openclaw_plugin(agent_id: str) -> None:
"""Install the lightweight hindsight-agent retain plugin into OpenClaw."""
# Check if plugin is already installed
openclaw_config = Path.home() / ".openclaw" / "openclaw.json"
if openclaw_config.exists():
config = json.loads(openclaw_config.read_text())
plugins = config.get("plugins", {}).get("entries", {})
if "hindsight-agent" in plugins:
click.echo(" Retain plugin already configured in OpenClaw.")
return
# Install the plugin package
if not OPENCLAW_PLUGIN_DIR.exists():
raise click.ClickException(
f"OpenClaw plugin not found at {OPENCLAW_PLUGIN_DIR}. "
"Make sure you're running from the hindsight-agent repo."
)
# Build the plugin if needed
dist_dir = OPENCLAW_PLUGIN_DIR / "dist"
if not dist_dir.exists():
click.echo(" Building retain plugin...")
result = subprocess.run(
["npm", "run", "build"],
cwd=OPENCLAW_PLUGIN_DIR,
capture_output=True, text=True,
)
if result.returncode != 0:
raise click.ClickException(f"Failed to build plugin: {result.stderr}")
# Install via openclaw CLI
result = subprocess.run(
["openclaw", "plugins", "install", str(OPENCLAW_PLUGIN_DIR)],
capture_output=True, text=True,
)
if result.returncode != 0:
# Fallback: manually add to config
click.echo(f" Plugin install via CLI failed ({result.stderr.strip()}), configuring manually...")
_manually_configure_openclaw_plugin(openclaw_config)
else:
click.echo(" Retain plugin installed.")
def _manually_configure_openclaw_plugin(openclaw_config: Path) -> None:
"""Add the plugin config directly to openclaw.json."""
config: dict = {}
if openclaw_config.exists():
config = json.loads(openclaw_config.read_text())
plugins = config.setdefault("plugins", {}).setdefault("entries", {})
plugins["hindsight-agent"] = {
"enabled": True,
"config": {},
}
openclaw_config.write_text(json.dumps(config, indent=2) + "\n")
click.echo(" Retain plugin configured in openclaw.json.")
def _setup_hermes(agent_id: str, workspace: Path) -> None:
"""Install the hindsight-agent memory plugin into Hermes and create profile if needed."""
hermes_home = Path.home() / ".hermes"
if not HERMES_PLUGIN_DIR.exists():
raise click.ClickException(
f"Hermes plugin not found at {HERMES_PLUGIN_DIR}. "
"Make sure you're running from the hindsight-agent repo."
)
# Create Hermes profile if it doesn't exist (skip for "default")
if agent_id != "default":
result = subprocess.run(
["hermes", "profile", "create", agent_id, "--clone", "--no-alias"],
capture_output=True, text=True,
)
if result.returncode == 0:
click.echo(f" Created Hermes profile '{agent_id}'.")
elif "already exists" in result.stderr.lower():
click.echo(f" Profile '{agent_id}' already exists.")
else:
click.echo(f" Note: Create profile manually: hermes profile create {agent_id}")
# Memory plugins live in the hermes-agent repo checkout
plugin_dest = hermes_home / "hermes-agent" / "plugins" / "memory" / "hindsight_agent"
plugin_dest.mkdir(parents=True, exist_ok=True)
for src_file in HERMES_PLUGIN_DIR.iterdir():
if src_file.is_file():
shutil.copy2(src_file, plugin_dest / src_file.name)
# Fix plugin name to match directory (no hyphens)
plugin_yaml = plugin_dest / "plugin.yaml"
if plugin_yaml.exists():
text = plugin_yaml.read_text()
text = text.replace("name: hindsight-agent", "name: hindsight_agent")
plugin_yaml.write_text(text)
click.echo(f" Retain plugin installed to {plugin_dest}")
# Set memory provider (--profile flag must come before subcommand)
if agent_id != "default":
config_cmd = ["hermes", "--profile", agent_id, "config", "set", "memory.provider", "hindsight_agent"]
else:
config_cmd = ["hermes", "config", "set", "memory.provider", "hindsight_agent"]
result = subprocess.run(config_cmd, capture_output=True, text=True)
if result.returncode == 0:
click.echo(" Memory provider set to hindsight_agent")
else:
click.echo(f" Note: Set memory provider with: hermes config set memory.provider hindsight_agent")
# Patch SOUL.md to always load the agent-knowledge skill
if agent_id != "default":
soul_path = hermes_home / "profiles" / agent_id / "SOUL.md"
else:
soul_path = hermes_home / "SOUL.md"
if soul_path.exists():
text = soul_path.read_text()
if "agent-knowledge" not in text:
text += (
"\n\n## Mandatory: Agent Knowledge\n\n"
"At the start of every session, load the `agent-knowledge` skill and "
"**execute its mandatory startup sequence** (run the commands, don't just read them). "
"This gives you access to your long-term knowledge pages.\n"
)
soul_path.write_text(text)
click.echo(" SOUL.md patched to auto-load agent-knowledge skill")
+74
View File
@@ -0,0 +1,74 @@
"""Global config for hindsight-agent.
Config lives at ~/.hindsight-agent/config.json and maps agent IDs to their
Hindsight environment (bank, api url).
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from pathlib import Path
CONFIG_DIR = Path.home() / ".hindsight-agent"
CONFIG_FILE = CONFIG_DIR / "config.json"
@dataclass
class AgentConfig:
bank_id: str
api_url: str
harness: str
workspace: str
api_token: str | None = None
def to_dict(self) -> dict:
d = asdict(self)
# Omit None token from serialized output
if d.get("api_token") is None:
d.pop("api_token", None)
return d
@staticmethod
def from_dict(d: dict) -> AgentConfig:
return AgentConfig(
bank_id=d["bank_id"],
api_url=d["api_url"],
harness=d["harness"],
workspace=d["workspace"],
api_token=d.get("api_token"),
)
def load_config() -> dict[str, AgentConfig]:
"""Load the global config. Returns empty dict if no config exists."""
if not CONFIG_FILE.exists():
return {}
raw = json.loads(CONFIG_FILE.read_text())
return {
agent_id: AgentConfig.from_dict(entry)
for agent_id, entry in raw.get("agents", {}).items()
}
def save_config(agents: dict[str, AgentConfig]) -> None:
"""Write the global config."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
raw = {"agents": {k: v.to_dict() for k, v in agents.items()}}
CONFIG_FILE.write_text(json.dumps(raw, indent=2) + "\n")
def get_agent(agent_id: str) -> AgentConfig:
"""Get config for a specific agent. Raises if not found."""
agents = load_config()
if agent_id not in agents:
raise click_missing_agent(agent_id)
return agents[agent_id]
def click_missing_agent(agent_id: str) -> SystemExit:
import click
raise click.ClickException(
f"Agent '{agent_id}' not found. Run 'hindsight-agent setup' first."
)
+216
View File
@@ -0,0 +1,216 @@
"""Hindsight Agent memory plugin for Hermes.
Lightweight retain-only plugin that reads agent config from
~/.hindsight-agent/config.json and retains conversations to the
correct Hindsight bank. The hindsight-agent CLI handles all
resolution (agent ID → bank, API URL, token).
This is NOT the full Hindsight memory plugin (plugins/memory/hindsight).
It's a thin retain layer designed to work alongside the agent-knowledge
skill, which provides pages, recall, and the self-learning loop.
"""
from __future__ import annotations
import json
import logging
import threading
import time
from pathlib import Path
from typing import Any
import httpx
from agent.memory_provider import MemoryProvider
logger = logging.getLogger(__name__)
# Resolve the REAL home directory at import time, before Hermes
# overrides $HOME for profile isolation. This ensures the plugin
# always finds the global config regardless of $HOME changes.
_REAL_HOME = Path.home()
CONFIG_PATH = _REAL_HOME / ".hindsight-agent" / "config.json"
def _find_config() -> Path | None:
"""Find config.json using the real home directory."""
if CONFIG_PATH.exists():
return CONFIG_PATH
return None
def _load_agent_config(agent_id: str) -> dict | None:
"""Load config for a specific agent."""
path = _find_config()
if not path:
return None
try:
data = json.loads(path.read_text())
return data.get("agents", {}).get(agent_id)
except Exception:
return None
def _load_all_agents() -> dict:
"""Load all agents."""
path = _find_config()
if not path:
return {}
try:
data = json.loads(path.read_text())
return data.get("agents", {})
except Exception:
return {}
class HindsightAgentProvider(MemoryProvider):
"""Retain-only memory provider that delegates to hindsight-agent config."""
def __init__(self) -> None:
self._agent_id: str | None = None
self._config: dict | None = None
self._session_id: str = ""
self._turn_count: int = 0
self._sync_thread: threading.Thread | None = None
@property
def name(self) -> str:
return "hindsight_agent"
def is_available(self) -> bool:
return CONFIG_PATH.exists()
def initialize(self, session_id: str, **kwargs: Any) -> None:
self._session_id = session_id
self._turn_count = 0
# Resolve agent ID from Hermes context
agent_identity = kwargs.get("agent_identity", "")
logger.info("[hindsight_agent] initialize: session=%s agent_identity=%s config=%s",
session_id, agent_identity, CONFIG_PATH)
self._config = None
self._agent_id = None
# Try exact match on profile name first
if agent_identity:
self._config = _load_agent_config(agent_identity)
if self._config:
self._agent_id = agent_identity
# Fallback: if only one hermes agent in config, use it
if not self._config:
agents = _load_all_agents()
hermes_agents = {aid: cfg for aid, cfg in agents.items() if cfg.get("harness") == "hermes"}
if len(hermes_agents) == 1:
self._agent_id, self._config = next(iter(hermes_agents.items()))
logger.info("[hindsight_agent] no exact match for '%s', using sole hermes agent '%s'",
agent_identity, self._agent_id)
elif len(hermes_agents) > 1:
logger.warning(
"[hindsight_agent] multiple hermes agents in config (%s) but profile '%s' doesn't match any. "
"Run: hindsight-agent setup %s --bank-id <bank> --harness hermes",
", ".join(hermes_agents.keys()), agent_identity, agent_identity,
)
if self._config:
logger.info(
"[hindsight_agent] initialized: agent=%s bank=%s",
self._agent_id,
self._config.get("bank_id"),
)
else:
logger.info(
"[hindsight_agent] no hermes agent in config, retain disabled",
)
def system_prompt_block(self) -> str:
# No system prompt injection — the skill handles reading pages
return ""
def prefetch(self, query: str, *, session_id: str = "") -> str:
# No prefetch — the skill handles recall via CLI
return ""
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
pass
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
"""Retain each turn immediately using append mode.
Uses document_id + update_mode=append so each turn adds to the
existing session document. Hindsight only processes the new content.
"""
if not self._config:
logger.debug("[hindsight_agent] sync_turn: skipped (no config)")
return
self._turn_count += 1
turn = [{"role": "user", "content": user_content}, {"role": "assistant", "content": assistant_content}]
logger.info("[hindsight_agent] sync_turn: turn %d, retaining (append)", self._turn_count)
self._do_retain_turn(turn)
def on_session_end(self, messages: list | None = None, **kwargs: Any) -> None:
"""No-op — each turn is retained immediately via sync_turn."""
logger.info("[hindsight_agent] on_session_end: %d turns retained for session",
self._turn_count)
def _do_retain_turn(self, turn: list[dict]) -> None:
"""Retain a single turn to Hindsight using append mode (background thread)."""
if not self._config:
return
bank_id = self._config["bank_id"]
api_url = self._config["api_url"].rstrip("/")
api_token = self._config.get("api_token")
content = json.dumps(turn)
document_id = f"{self._agent_id}:{self._session_id}" if self._session_id else None
item: dict = {"content": content, "update_mode": "append"}
if document_id:
item["document_id"] = document_id
url = f"{api_url}/v1/default/banks/{bank_id}/memories"
headers: dict[str, str] = {"Content-Type": "application/json"}
if api_token:
headers["Authorization"] = f"Bearer {api_token}"
turn_num = self._turn_count
def _retain() -> None:
try:
resp = httpx.post(
url,
json={"items": [item], "async": True},
headers=headers,
timeout=30.0,
)
if resp.is_success:
logger.info("[hindsight_agent] retained turn %d for %s", turn_num, self._agent_id)
else:
logger.warning("[hindsight_agent] retain failed (%d): %s",
resp.status_code, resp.text[:200])
except Exception as e:
logger.warning("[hindsight_agent] retain error: %s", e)
if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=5.0)
self._sync_thread = threading.Thread(target=_retain, daemon=True, name="hindsight-agent-retain")
self._sync_thread.start()
def get_tool_schemas(self) -> list[dict]:
# No tools — the skill provides CLI-based access
return []
def handle_tool_call(self, tool_name: str, args: dict, **kwargs: Any) -> str:
return json.dumps({"error": f"Unknown tool: {tool_name}"})
def shutdown(self) -> None:
if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=5.0)
def register(ctx: Any) -> None:
"""Register as a Hermes memory provider plugin."""
ctx.register_memory_provider(HindsightAgentProvider())
@@ -0,0 +1,8 @@
name: hindsight_agent
version: 0.1.0
description: "Lightweight Hindsight retain plugin for hindsight-agent. Reads bank/URL from ~/.hindsight-agent/config.json."
pip_dependencies:
- "httpx>=0.27"
requires_env: []
hooks:
- on_session_end
@@ -0,0 +1,16 @@
{
"id": "hindsight-agent",
"name": "Hindsight Agent Retain",
"kind": "action",
"configSchema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable/disable conversation retention"
}
},
"additionalProperties": false
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@hindsight/agent-retain",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@hindsight/agent-retain",
"version": "0.1.0",
"devDependencies": {
"typescript": "^5.4"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
@@ -0,0 +1,17 @@
{
"name": "@hindsight/agent-retain",
"version": "0.1.0",
"description": "Lightweight OpenClaw plugin that retains conversations via hindsight-agent CLI",
"type": "module",
"main": "dist/index.js",
"openclaw": {
"extensions": ["./dist/index.js"]
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"typescript": "^5.4"
}
}
@@ -0,0 +1,128 @@
/**
* Lightweight OpenClaw plugin for Hindsight Agent.
*
* On every agent_end, reads the agent config from ~/.hindsight-agent/config.json
* to resolve bank ID and API URL, then POSTs filtered messages to Hindsight.
*
* No child_process. The config file is the single source of truth,
* written by `hindsight-agent setup`.
*/
import { readFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
interface PluginAPI {
config: any;
on(event: string, handler: (event: any, ctx?: any) => void | Promise<void>): void;
logger: {
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
};
}
interface AgentContext {
agentId?: string;
sessionKey?: string;
workspaceDir?: string;
}
interface Message {
role: string;
content: string | any[];
}
interface AgentConfig {
bank_id: string;
api_url: string;
api_token?: string;
}
const CONFIG_PATH = join(homedir(), ".hindsight-agent", "config.json");
function loadAgentConfig(agentId: string): AgentConfig | null {
try {
const raw = readFileSync(CONFIG_PATH, "utf-8");
const config = JSON.parse(raw);
return config.agents?.[agentId] ?? null;
} catch {
return null;
}
}
const ALLOWED_ROLES = new Set(["user", "assistant"]);
function filterMessages(messages: Message[]): Array<{ role: string; content: string }> {
const result: Array<{ role: string; content: string }> = [];
for (const msg of messages) {
const role = msg.role ?? "unknown";
if (!ALLOWED_ROLES.has(role)) continue;
let text = "";
if (typeof msg.content === "string") {
text = msg.content;
} else if (Array.isArray(msg.content)) {
text = msg.content
.filter((block: any) => block?.type === "text" && block.text)
.map((block: any) => block.text)
.join("\n");
}
if (!text.trim()) continue;
result.push({ role, content: text });
}
return result;
}
export default function (api: PluginAPI) {
const log = api.logger;
api.on("agent_end", async (event: any, ctx?: AgentContext) => {
const agentId = ctx?.agentId;
if (!agentId || !event?.success) return;
const agentConfig = loadAgentConfig(agentId);
if (!agentConfig) return;
const messages: Message[] =
event.context?.sessionEntry?.messages ?? event.messages ?? [];
if (!messages.length) return;
const filtered = filterMessages(messages);
if (!filtered.length) return;
const content = JSON.stringify(filtered);
const sessionId = event.sessionKey ?? ctx?.sessionKey;
const documentId = sessionId ? `${agentId}:${sessionId}` : undefined;
const item: Record<string, any> = { content };
if (documentId) item.document_id = documentId;
const url = `${agentConfig.api_url}/v1/default/banks/${agentConfig.bank_id}/memories`;
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(agentConfig.api_token ? { Authorization: `Bearer ${agentConfig.api_token}` } : {}),
},
body: JSON.stringify({ items: [item], async: true }),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
const body = await response.text().catch(() => "");
log.error(`[hindsight-agent] retain failed (${response.status}): ${body}`);
return;
}
log.info(`[hindsight-agent] retained ${filtered.length} messages for ${agentId}`);
} catch (err: any) {
log.error(`[hindsight-agent] retain error: ${err.message}`);
}
});
}
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"declaration": true,
"skipLibCheck": true
},
"include": ["src"]
}
+16
View File
@@ -0,0 +1,16 @@
[project]
name = "hindsight-agent"
version = "0.1.0"
description = "Agent scaffolding and runtime CLI for Hindsight memory"
requires-python = ">=3.11"
dependencies = [
"click>=8.1",
"httpx>=0.27",
]
[project.scripts]
hindsight-agent = "hindsight_agent.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+179
View File
@@ -0,0 +1,179 @@
---
name: agent-knowledge
description: Manage your long-term knowledge. Read existing topic pages before acting. Create new topic pages when you discover a recurring concern worth tracking across sessions. Query memories for deeper research. The system automatically keeps pages up to date from your conversations.
---
# Agent Knowledge
Your knowledge is stored as topic pages that the system keeps updated automatically from your conversations. You **read** pages, **create** new ones when needed, and **recall** memories for deeper research. You never edit page content directly — the system handles that.
All commands use `hindsight-agent <agent-id>`. To find your agent ID:
1. Check if `{{AGENT_ID}}` is set above (setup bakes it in). If it's a placeholder or wrong, continue.
2. Your agent ID is usually your profile/agent name (e.g., your Hermes profile name or OpenClaw agent name).
3. If unsure, run `hindsight-agent list` to see all configured agents and pick the one that matches you.
## Mandatory startup sequence
Run these silently at the start of every session:
```bash
hindsight-agent pages list <your-agent-id>
```
Read the pages relevant to the current task. If the list is empty, that's fine — create pages as you learn things (see below).
## Reading pages
```bash
# List all pages (names + content)
hindsight-agent pages list {{AGENT_ID}}
# Read one specific page
hindsight-agent pages get {{AGENT_ID}} <page_id>
```
## Recalling memories
Use recall to search across all retained knowledge — conversations, reference documents, observations. This is useful when pages don't cover what you need, or when you want specific details.
```bash
# Search memories
hindsight-agent recall {{AGENT_ID}} "<natural language query>"
# Limit results
hindsight-agent recall {{AGENT_ID}} "<query>" -n 5
# Filter by type (world, experience, observation)
hindsight-agent recall {{AGENT_ID}} "<query>" --type observation
```
Use recall when:
- You need specific facts not covered by your pages
- You want to verify something before making a decision
- You're looking for evidence to support a recommendation
- You want to check what reference documents say about a topic
## Ingesting documents
Upload content directly into your memory. The system handles chunking, extraction, and indexing — you just pass the raw content through. **Never summarize or truncate content before ingesting.** Pass it raw, no matter how large. The system can handle it.
```bash
# From a file (preferred for large content)
hindsight-agent ingest-document {{AGENT_ID}} "Document Title" -f /path/to/file.md
# Inline for short content
hindsight-agent ingest-document {{AGENT_ID}} "Document Title" -c "content here..."
# Pipe from another command (e.g., fetched web content)
cat data.txt | hindsight-agent ingest-document {{AGENT_ID}} "Document Title"
```
The title is used as the document ID — re-ingesting the same title replaces the previous version.
**Important:** When ingesting web pages or large documents, save the content to a file first, then ingest with `-f`. Do NOT try to pass large content inline with `-c` — write it to a temp file and use `-f`. Do NOT summarize, rewrite, or "optimize" the content — the system extracts what it needs from raw text.
## Listing reference documents
```bash
hindsight-agent documents {{AGENT_ID}}
```
This shows what content has been retained into your memory — reference documents, conversation transcripts, etc. Use it to understand what knowledge is available for recall.
## Creating pages
When you discover a recurring topic worth tracking across sessions — user preferences, a procedure that works, performance data — create a page for it. Use your judgment.
```bash
hindsight-agent pages create {{AGENT_ID}} <page-id> "<Page Name>" "<source_query>"
```
The page ID must be lowercase with hyphens (e.g., `seo-best-practices`, `editorial-preferences`).
**The `source_query` is the key field.** It's a question the system will re-ask on every consolidation to rebuild the page content from your accumulated observations. Write it using the patterns below.
### Source query patterns
Use these patterns to write effective source queries:
**For best practices (combining reference docs with user feedback):**
```
What are the best practices for [topic], combining industry standards
with what has actually worked for us? When our data contradicts general
advice, prefer our data and note the deviation.
```
**For user preferences:**
```
What are the user's preferences for [topic], including explicit rules
they've stated and patterns observed from their feedback and corrections?
```
**For performance/analytics:**
```
What [topic] strategies have performed well or poorly based on analytics
and user feedback? Include specific numbers when available. What patterns
emerge about what works vs what doesn't?
```
**For procedures:**
```
What is the current procedure for [topic]? Include steps, tools used,
and any lessons learned from past attempts.
```
**When to create a page:**
- The user stated a durable preference or rule — do it immediately, don't wait
- You discovered a procedure that works and want to remember it
- You have performance data that should inform future decisions
- On your first session with no pages: create at least one broad page for core preferences
**When NOT to create a page:**
- One-off facts (just acknowledge and move on — the system retains the conversation)
- Things that are already covered by an existing page
- Agent internals, tool usage, or delivered content
## Updating a page's source query
If a page's scope needs to change — broader, narrower, or refocused — update its source_query. The system will re-synthesize the content on next consolidation.
```bash
hindsight-agent pages update {{AGENT_ID}} <page_id> --source-query "Updated question..."
```
You can also rename a page:
```bash
hindsight-agent pages update {{AGENT_ID}} <page_id> --name "Better Name"
```
## Deleting a page
If a page is redundant, outdated, or was a mistake — delete it:
```bash
hindsight-agent pages delete {{AGENT_ID}} <page_id>
```
Do this silently. Don't ask the user for permission to clean up your own knowledge.
## How pages stay current
1. Every conversation turn is automatically retained by the system
2. The system extracts observations from your conversations
3. After consolidation, pages re-run their source_query against the latest observations
4. Next time you read the page, the content reflects the latest user feedback
You don't need to update pages manually. The system captures your conversation automatically. To make sure important information reaches your pages:
- **State preferences and decisions explicitly in your response.** If the user says "keep posts short," respond with something like "Noted — keeping posts to 800 words max." That clear statement gets retained and extracted as an observation.
- **Summarize key takeaways in your replies.** Don't just act on information silently — say what you learned. "Based on your analytics, comparison posts outperform explainers 3:1" gives the system a clean observation to work with.
- **The system only knows what you say in the conversation.** If you learn something but don't mention it in your response, it won't be captured.
## Rules
- **Never edit page content directly** — the system synthesizes it from observations
- **Never ask the user about knowledge structure** — which pages exist, naming, organization. That's your decision, invisible to the user.
- **Create pages silently** — don't announce "I'm creating a page for X". Just do it.
- **Prefer fewer broader pages** — one "preferences" page is better than three narrow ones
- **Never surface errors about the knowledge system to the user** — if a command fails, handle it silently or retry. The user should not know about the infrastructure.
- **Use recall for ad-hoc research** — pages are for durable knowledge; recall is for one-off lookups
+116
View File
@@ -0,0 +1,116 @@
version = 1
revision = 1
requires-python = ">=3.11"
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 },
]
[[package]]
name = "click"
version = "8.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
]
[[package]]
name = "hindsight-agent"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "httpx" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1" },
{ name = "httpx", specifier = ">=0.27" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
]
+1 -1
View File
@@ -3593,7 +3593,7 @@ def _register_routes(app: FastAPI):
detail="This endpoint is deprecated. Entity observations are no longer supported.",
)
# =========================================================================
#
# =========================================================================
# MENTAL MODELS ENDPOINTS (stored reflect responses)
# =========================================================================
@@ -609,6 +609,8 @@ async def run_consolidation_job(
if timing_parts:
perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}")
# Trigger mental model refreshes only on the final round (when all memories are processed).
# If we hit the round limit and re-queued, skip MM refresh — the next round will handle it.
# Trigger mental model refreshes only on the final round (when all memories are processed).
# If we hit the round limit and re-queued, skip MM refresh — the next round will handle it.
if hit_round_limit:
@@ -6771,17 +6771,20 @@ class MemoryEngine(MemoryEngineInterface):
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
# Build tag filter
tag_filter = ""
# Build filters
extra_filters = ""
params: list[Any] = [bank_id, limit, offset]
next_idx = 4
if tags:
if tags_match == "all":
tag_filter = " AND tags @> $4::varchar[]"
extra_filters += f" AND tags @> ${next_idx}::varchar[]"
elif tags_match == "exact":
tag_filter = " AND tags = $4::varchar[]"
extra_filters += f" AND tags = ${next_idx}::varchar[]"
else: # any
tag_filter = " AND tags && $4::varchar[]"
extra_filters += f" AND tags && ${next_idx}::varchar[]"
params.append(tags)
next_idx += 1
rows = await conn.fetch(
f"""
@@ -6789,7 +6792,7 @@ class MemoryEngine(MemoryEngineInterface):
last_refreshed_at, created_at, reflect_response,
max_tokens, trigger, structured_content
FROM {fq_table("mental_models")}
WHERE bank_id = $1 {tag_filter}
WHERE bank_id = $1 {extra_filters}
ORDER BY last_refreshed_at DESC
LIMIT $2 OFFSET $3
""",
@@ -247,8 +247,8 @@ export function MentalModelsView() {
</div>
) : (
<>
{/* Search filter */}
<div className="mb-4">
{/* Search filter + KB selector */}
<div className="mb-4 flex items-center gap-3">
<Input
type="text"
value={searchQuery}
+3 -1
View File
@@ -54,7 +54,9 @@
".next-54840/types/**/*.ts",
".next-54840/dev/types/**/*.ts",
".next-64856/types/**/*.ts",
".next-64856/dev/types/**/*.ts"
".next-64856/dev/types/**/*.ts",
".next-8889/types/**/*.ts",
".next-8889/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
+99
View File
@@ -0,0 +1,99 @@
# Open Questions — Unresolved (Need Your Input)
These are the questions I couldn't address from our existing work. Each requires either running experiments, collecting data, or making a decision that only you can make.
---
## Empirical Claims Needing Data
### Q1: Formalized write-reliability measurement
The paper claims ~30% drop rate from ~40 manual observations. For a conference submission, we need a proper experiment: N=100+ sessions with controlled inputs ("remember that I prefer X"), automated check for whether the preference appears in memory files. Report with confidence intervals.
**What's needed:** Design the experiment, run it, report numbers.
### Q2: Write reliability by mechanism (tool calls vs file writes)
The paper acknowledges tool-based writes are likely more reliable, but argues the file-write pattern is the realistic baseline. A reviewer may push back: "if tool calls fix it, your motivation collapses." A controlled comparison (same agent, same sessions, three conditions) would settle this.
**What's needed:** Decide if we run this experiment or argue it's out of scope.
### Q3: Consolidation latency P50/P95/P99
The paper says "consolidation cycle dependent" but doesn't give numbers. We need instrumented measurements under various loads.
**What's needed:** Instrument the pipeline, run 100+ cycles, report distribution.
### Q4: "Page quality was poor" — formalized measurement
Currently based on manual inspection (70% junk rate). Need a rubric, 2+ raters, inter-rater agreement.
**What's needed:** Design rubric, rate pages, report.
---
## Missing Experiments
### Q5: End-to-end task quality comparison (HARD BLOCKER)
The most important missing evidence. A benchmark where an agent serves a user across N sessions, user states preferences, and we measure adherence in later sessions. Compare: no memory, file-based (Approach 1), pipeline (Approach 2), our approach (Approach 3).
**Proposal:** 10 user preferences stated across sessions 1-3, measure adherence score in sessions 4-10. Could use AMB or build a simpler preference-adherence benchmark.
**What's needed:** Design and run the benchmark. This is the #1 priority.
### Q6: Cross-session transfer experiment with numbers
User states preference in session 1, measure whether agent honors it in session N. Compare to no-memory baseline and Approach 1.
**What's needed:** This may overlap with Q5. Decide if it's part of the same experiment or separate.
### Q7: source_query phrasing stability ablation
Pick 3-5 queries, write 5 paraphrases each, run all, measure content overlap (ROUGE/BERTScore). Validates the "steerable" claim.
**What's needed:** Design and run. Medium priority — can be acknowledged as future work if we're short on time.
### Q8: Delta vs full mode empirical comparison
Quality degradation over long horizons, cost savings, drift measurement. Currently asserted.
**What's needed:** Run a page through 20+ consolidation cycles with incremental observations. Compare delta vs full on quality + cost.
### Q9: Observation extraction faithfulness
On a labeled set of conversations, measure precision/recall/fabrication of observation extraction.
**What's needed:** Label 20 conversations with ground-truth, run extraction, measure.
### Q10: Scaling behavior
As observations grow (100 → 10k → 100k), what happens to synthesis cost, quality, latency?
**What's needed:** Synthetic scaling test. Lower priority — can acknowledge as future work.
---
## Deployment Data
### Q11: End-to-end template example with real data
The paper's Section 5.3 has a sketch. For the submission, include actual page content before/after user feedback, with screenshots from the control plane.
**What's needed:** Run the marketing-seo demo end-to-end, capture actual page content at each stage.
### Q12: Deployment numbers
How many agents, sessions, pages, typical page size, observation counts. Grounds the work.
**What's needed:** Systematic logging. Or just report what we have (~5 agents, ~100 sessions, 3-5 pages/agent, 50-500 observations/bank).
---
## Priority for Conference Submission
**Hard blockers (must do):**
1. Q5 — end-to-end quality benchmark
2. Q6 — cross-session transfer with numbers
**Should do:**
3. Q1 — formalized write-reliability
4. Q9 — observation extraction faithfulness
5. Q11 — real template example with data
**Can acknowledge as future work:**
6. Q7 — phrasing stability
7. Q8 — delta vs full comparison
8. Q10 — scaling
9. Q2 — tool call vs file write comparison
10. Q3 — latency distribution
11. Q4 — formalized quality measurement
12. Q12 — deployment numbers
+354
View File
@@ -0,0 +1,354 @@
# Asynchronous Knowledge Synthesis for Self-Learning LLM Agents
**Draft — April 2026**
## Abstract
We present an architecture for self-learning LLM agents that separates knowledge capture from knowledge synthesis, addressing the fundamental unreliability of LLM agents as writers of their own persistent state. Our approach uses an external memory system to deterministically capture every agent conversation, asynchronously extract structured observations, and maintain evolving knowledge pages via a novel *synthesis query* abstraction. The agent reads its knowledge at session start and decides what topics to track, but never writes page content directly — the system handles synthesis in the background. We demonstrate that this separation achieves deterministic capture of all completed sessions (vs ~70% with agent-driven writes), eliminates synchronous write overhead from the agent's critical path, and produces higher-quality knowledge pages because synthesis operates on accumulated observations rather than single-turn context. We compare against file-based self-maintaining memory, pipeline-driven auto-creation, and the Memento reflective learning approach, identifying the failure modes of each and the design constraints that led to our architecture.
## 1. Introduction
Long-running LLM agents — those that operate across multiple sessions serving the same user or domain — face a fundamental problem: they wake up stateless. Each session begins with no memory of prior interactions unless external mechanisms provide continuity. The emerging solution is agent memory systems that persist knowledge across sessions, but the question of *who maintains that knowledge* remains open.
Three approaches exist in the literature and practice:
1. **Agent-maintained files.** The agent reads and writes its own memory files (markdown, JSON, git-tracked). Used by Claude Code's auto-memory, OpenClaw's MEMORY.md pattern, and many custom agent frameworks. Simple, zero infrastructure, but depends on the agent reliably executing post-response write operations.
2. **Pipeline-maintained knowledge.** An external system ingests conversation transcripts and uses LLM calls to extract, organize, and synthesize knowledge. The agent is read-only. Examples include RAG systems with periodic re-indexing, and Karpathy's LLM Wiki pattern [1] where an LLM maintains a structured wiki from raw document sources.
3. **Hybrid: agent-directed, system-maintained.** The agent decides *what* to track (creates knowledge pages with synthesis queries), but the system handles *capture* (deterministic hooks) and *synthesis* (asynchronous background processing). This is our approach.
We argue that approach (3) is necessary because (1) fails on write reliability and (2) fails on content curation. We present evidence from building and testing all three approaches with real agents on the OpenClaw platform, backed by the Hindsight memory system.
**Thesis.** For persistent cross-session agent memory, capture must be deterministic, synthesis must be asynchronous, and agent-directed curation is the minimal coupling that achieves both reliability and quality.
## 2. Background and Related Work
### 2.1 The Unreliable Writer Problem
LLM agents are stateless function calls. When asked to both produce a visible response AND perform invisible bookkeeping (update memory files, append logs, commit changes), the bookkeeping competes with the primary task for the model's "attention budget." In our experiments (Section 4.1), agents dropped post-response memory writes approximately 30% of the time — they understood the rules, agreed to follow them, and then didn't execute the final steps.
This is not a prompting problem. We tested mandatory checklists (`📝 Memory: [wrote: X | logged: Y | committed: Z]`), which improved reliability but never eliminated the failure. The LLM's natural stopping point is after the visible response — everything after that is a bonus the model may or may not execute.
The unreliable writer problem is recognized in the agent architecture literature. Sumers et al. [6] identify write reliability as a core open challenge in their taxonomy of cognitive architectures for language agents, noting that agents frequently fail to persist state to external memory even when given tools to do so. Packer et al. [3] address it by giving the agent explicit memory management syscalls, but the agent must still decide *when* to call them — the decision itself is the failure point.
A natural question is whether tool-based writes (explicit function calls to a memory API) are more reliable than post-response file writes. We believe they are — tool calls are part of the agent's action sequence, not an afterthought. However, the realistic baseline in production agent frameworks today is the file-write pattern (Claude Code auto-memory, OpenClaw MEMORY.md, LETTA's agent-controlled memory), and our experiments (Section 4.1) show that even when the agent agrees it should persist something, the decision to actually execute the write is the failure point — not the write mechanism itself.
### 2.2 Generative Agents and the Reflection Hierarchy
Park et al. [8] introduced a three-level memory hierarchy for generative agents: *streams* (raw observations), *reflections* (higher-level summaries synthesized from streams), and *plans* (intentions derived from reflections). Their architecture established that agents benefit from derived knowledge layers, not just raw memory retrieval.
Our architecture follows an analogous hierarchy: *conversations* (raw transcripts, captured deterministically) → *observations* (structured facts extracted by a pipeline) → *knowledge pages* (synthesized documents rebuilt via synthesis queries). The critical difference is *who drives the synthesis*:
- In Generative Agents, reflection is **agent-driven and synchronous** — the agent generates reflections as part of its reasoning cycle, competing with the primary task for compute and attention.
- In our system, observation extraction and page synthesis are **system-driven and asynchronous** — they run in a background pipeline after the agent's turn is complete.
This is not merely an implementation choice. Our experiments (Section 4.1) demonstrate that making the agent responsible for any post-response processing introduces a reliability gap. By moving synthesis off the agent's critical path, we eliminate this failure mode entirely.
### 2.3 Karpathy's LLM Wiki
Karpathy proposed a pattern for LLM-maintained knowledge bases [1]: raw document sources are ingested, an LLM maintains a structured wiki, and three operations keep it current — **ingest** (add new sources), **query** (retrieve relevant sections), and **lint** (check consistency and freshness). The LLM does all the writing; the wiki evolves as sources change.
This maps cleanly to agent memory: conversation transcripts are the sources, knowledge pages are the wiki, and consolidation is the maintenance loop. However, Karpathy's model assumes curated document inputs where the LLM can identify topic boundaries. Agent conversation transcripts are 80%+ noise — tool calls, formatting, agent self-talk, delivered content — and a pipeline LLM cannot reliably distinguish signal from noise in this context (Section 4.2).
### 2.4 Memento
Memento [2] proposes a read-write reflective learning framework where agents maintain skill files as persistent memory. The agent rewrites skill files directly after each session, with a judge LLM + unit tests + rollback mechanism to prevent regressions. Key contributions: behavior-aligned routing (matching tasks to relevant skills), convergence guarantees (skills stabilize over iterations), and the insight that skills themselves are the right unit of persistent memory.
Our approach shares the premise (skills as memory, reflective learning) but differs in a critical design choice: Memento lets the agent write content directly (with safeguards), while we separate content creation from content maintenance. Their approach requires heavier infrastructure (judge, test gate, rollback) to compensate for the unreliability we avoid by design. The trade-off: they get immediate updates within a session; we accept consolidation latency in exchange for guaranteed capture and background synthesis.
### 2.5 Other Agent Memory Systems
**MemGPT** [3] virtualizes the context window with an explicit memory management system, giving the agent control over what enters and exits working memory. Relevant but orthogonal — it addresses within-session memory management, not cross-session knowledge persistence. Its successor, Letta, continues this agent-controlled approach.
**Reflexion** [4] introduces self-reflection where agents generate verbal feedback on their own outputs and use it in subsequent attempts. The reflection is immediate and task-specific, not persisted across sessions. Our observation extraction is similar in spirit but operates asynchronously and accumulates across all sessions.
**Voyager** [5] builds a skill library in Minecraft where the agent writes executable code snippets as reusable skills. The skill library persists and grows. Similar to our page creation — the agent decides what's worth persisting — but Voyager skills are executable programs, not synthesized knowledge, and there's no background refinement.
**MemoryBank** [9] introduces Ebbinghaus-inspired forgetting curves for agent memory, providing a principled mechanism for memory decay. Our system currently lacks a forgetting mechanism — observations accumulate indefinitely (Section 6). MemoryBank's decay model is a natural extension for managing long-term observation growth.
**A-MEM** [10] proposes dynamic memory organization inspired by Zettelkasten, with linking between memory nodes. Relevant to our future work on page organization and cross-page references.
### 2.6 Practitioner Memory Systems
Several production systems address overlapping concerns: **mem0** provides key-value memory with auto-extraction (similar to our observation extraction but without the page synthesis layer); **Zep** offers conversation memory with entity extraction and temporal queries (similar infrastructure but no knowledge page synthesis). These systems validate the need for structured agent memory but do not address the capture/curation/synthesis separation that is our core contribution.
## 3. Architecture
### 3.1 Overview
Our system consists of four components:
1. **Capture layer** — A deterministic plugin hook that fires on every completed agent conversation, retaining the user/assistant message history into a memory bank. The agent is not involved; capture is infrastructure. "Deterministic" means the hook fires on every `agent_end` event without an LLM decision point — if the session completes normally, the conversation is captured. Edge cases (session crashes before the hook fires, network failures on the retain POST, memory system downtime) are infrastructure failures, not LLM reliability failures — the same class of failure that affects any distributed system.
2. **Consolidation pipeline** — An asynchronous background process that extracts structured observations from retained conversations. Runs periodically, not on the critical path of any agent response.
3. **Knowledge pages** — Persistent, evolving documents that synthesize observations into actionable knowledge. Each page is defined by a *synthesis query* — a natural language question that the system re-answers after every consolidation cycle using the latest observations. (The implementation uses the field name `source_query`; we use "synthesis query" in this paper to better convey its dual role as both a retrieval query and a synthesis specification.)
4. **Agent skill** — A read-heavy interface that the agent uses at session startup to read its knowledge pages, and occasionally to create new pages, update their scope, or query raw memories for ad-hoc research.
### 3.2 The Synthesis Query Abstraction
The key design innovation is the synthesis query. When the agent creates a knowledge page, it provides:
- A **name** (human-readable label)
- A **synthesis query** (a question the system will re-ask on every consolidation)
For example:
```
name: "Editorial Preferences"
synthesis_query: "What are the user's editorial preferences for blog content,
including tone, voice, length, formatting rules, and any explicit corrections
they've stated? Include patterns from feedback."
```
The system uses this query to run a reflect operation against all accumulated observations, producing synthesized content. After each consolidation cycle — when new observations have been extracted from recent conversations — the page automatically refreshes by re-running its synthesis query against the updated observation set.
This abstraction has several properties:
- **Declarative, not imperative.** The agent specifies *what* it wants to know, not *how* to maintain the knowledge.
- **Idempotent.** Re-running the query produces a complete, self-contained page — not a diff or append.
- **Steerable.** The query's phrasing controls how the synthesis resolves conflicts (e.g., "when our data contradicts industry advice, prefer our data and note the deviation"). We observe in practice that this steering is effective, though formal stability analysis across paraphrasings is future work (Section 6).
- **Evolvable.** The agent can update the synthesis query if the page's scope needs to change.
### 3.3 Data Flow
```
Session 1: User says "keep posts to 800 words max"
→ auto-retain captures conversation (deterministic)
→ consolidation extracts observation: "user wants 800 word max for posts"
→ "Editorial Preferences" page refreshes via synthesis query
→ page now includes "800 word max" alongside other preferences
Session 2: Agent reads "Editorial Preferences" page at startup
→ writes an 800-word post without being told
```
The agent never edited the page. It acknowledged the preference in conversation (so retain captures it), and the system did the rest.
### 3.4 Delta Mode
Pages can operate in **full** or **delta** mode:
- **Full mode**: On each refresh, re-synthesize the entire page from all observations. Produces the most coherent result but scales poorly with observation count.
- **Delta mode**: On each refresh, only process observations since the last refresh and merge them into the existing page content. More efficient, preserves existing structure, but requires the synthesis to handle merging.
In practice, delta mode is preferred for production use — it limits the LLM call size to new observations only, and the accumulated page content provides continuity. Formal comparison of quality degradation over long horizons is future work (Section 6).
### 3.5 Page Discovery and Versioning
At session startup, the agent enumerates all pages via a list command. At current scale (3-10 pages per agent), full enumeration is practical. At larger scale (50+ pages), tag-based filtering or relevance-based selection would be needed.
Pages are versioned: each refresh creates a history entry with the previous content and timestamp. This provides an audit trail — the user or developer can see how a page evolved over time, which is important for debugging synthesis quality issues.
### 3.6 Pages vs. Raw Recall
The agent has two retrieval modes: **page reads** (synthesized knowledge) and **recall queries** (raw memory search). Pages are the "compiled" form — durable, structured, updated automatically. Recall is the "raw" form — ad-hoc, specific, useful when the agent needs a particular fact or number not captured in any page. The agent skill teaches this distinction: read pages for broad context at session startup; use recall for targeted lookups during a task.
### 3.7 What the Agent Controls vs. What the System Controls
| Responsibility | Agent | System |
|---|---|---|
| Capture conversations | Nothing | Deterministic plugin hook |
| Create knowledge pages | Decides what topics need a page, writes the synthesis query | Stores the page, runs initial synthesis |
| Update page content | Nothing — just responds naturally to user feedback | Consolidation + refresh handles it |
| Update page scope | Can modify the synthesis query if the page needs refocusing | Re-synthesizes on next cycle |
| Delete pages | Can delete redundant pages | Removes them |
| Read knowledge | Reads pages at session startup | Returns current content |
| Ad-hoc research | Runs recall queries | Semantic search across all observations |
## 4. Experiments and Findings
### 4.1 Agent-Maintained File Memory (Approach 1)
We built an `agent-memory` skill where the agent maintains its own wiki of markdown files — one per topic, with evidence sections, git-tracked, and indexed. The agent reads before acting and writes after responding.
**Setup:** The skill defined a mandatory post-response checklist: update knowledge files, append to activity log, git commit. A completion marker (`📝 Memory: [wrote: X | logged: Y | committed: Z]`) was required at the end of every response. Testing was conducted across ~40 sessions with 3 different agents (news-feed, marketing-seo, discord-watch) on the OpenClaw platform over 2 weeks.
**Results:**
- Read reliability: ~100%. Agents consistently read memory files when instructed at session startup.
- Write reliability: ~70%. Post-response writes were dropped in approximately 30% of sessions.
- The checklist improved reliability from ~50% to ~70% but never eliminated the problem.
- When writes succeeded, the quality was good — the agent understood what to persist and how to organize it.
**Failure mode distribution** (across observed failures):
- ~60%: dropped entirely — agent produced response, stopped, never attempted the write
- ~25%: partial — wrote to one file but not the activity log, or wrote but didn't commit
- ~15%: wrong content — wrote a summary that missed key details, or wrote to wrong file
**Failure analysis:** The LLM's generation terminates when it produces a natural response endpoint (answer delivered, task completed). Post-response bookkeeping requires the model to continue generating after this natural stopping point. This is architecturally similar to the "last-mile" problem in multi-step reasoning — the model handles the main task well but drops auxiliary steps.
### 4.2 Pipeline-Maintained Knowledge (Approach 2)
We built a `knowledge_base_update` pipeline that runs after consolidation: it reads the bank's mission, recent observations, and existing pages, then asks an LLM whether new pages should be created or existing ones reorganized.
**Results:**
- The pipeline reliably created pages — no write reliability issues (it's server-side code, not an agent).
- However, page quality was poor. Across 3 banks over 5 consolidation cycles each, approximately 70% of auto-created pages were irrelevant to the bank's stated mission. The LLM consistently created pages for:
- "Open Source AI Models" (from news content the agent delivered)
- "Agent Identity" (from session setup chatter)
- "Tool Usage Patterns" (from tool call metadata)
**Mitigations attempted:**
1. Strict prompt rules ("NEVER create pages for delivered content") — LLM ignores them
2. Code-level observation filters (pattern matching) — fragile, wrong approach
3. Requiring 3+ observations per topic — still creates junk from clustered noise
**Failure analysis:** Observations extracted from conversation transcripts are decontextualized. A statement like "GPT-5.4 is now available" might be a news item the agent delivered or a user preference about which model to use — the pipeline LLM cannot tell the difference. The agent can, because it has the full conversation context and understands what matters to the user.
This is where Karpathy's LLM Wiki pattern breaks for agent memory: his model assumes curated document inputs, while our inputs are noisy conversation transcripts.
### 4.3 Hybrid: Agent-Directed, System-Maintained (Approach 3)
Our final architecture: the agent creates pages (it has context to judge what matters), the system refreshes them (it has reliability).
**Results:**
- Capture reliability: deterministic for all completed sessions (hook fires on every `agent_end` event)
- Page creation quality: high (agent only creates pages for topics it recognizes as recurring)
- Cross-session knowledge transfer: confirmed. Preferences stated in session N appeared in synthesized pages read by session N+1 (after consolidation).
- Synthesis latency: consolidation cycle dependent — acceptable for cross-session use; within a session, the agent applies feedback from direct conversation context.
**Key insight confirmed:** Separating the decision of *what to track* (agent) from the *mechanics of tracking* (system) produces the best outcome. Neither the agent alone (unreliable writes) nor the pipeline alone (junk pages) achieves both reliability and quality.
## 5. Discussion
### 5.1 The Async Latency Trade-off
The primary cost of our approach is latency: knowledge pages are not updated in real-time. After a user states a preference, the system requires a consolidation cycle (observation extraction) followed by a page refresh before the knowledge is available to future sessions.
Within the current session, this is not a problem — the agent has the conversation context and can apply the preference immediately. The latency only affects cross-session transfer. In practice, with consolidation running every few minutes, this delay is acceptable for the use cases we target (durable preferences, procedures, performance data).
### 5.2 The Synthesis Query as a Steerable Lens
The synthesis query is more than a retrieval query — it's a steerable lens that determines how raw observations are synthesized into knowledge. Different phrasings produce different pages from the same observations:
- "What are the best practices?" → produces a rule list
- "What has performed well vs poorly?" → produces a comparative analysis
- "What are the best practices, preferring our data over industry advice?" → produces personalized rules with deviation notes
This gives the agent (and by extension, the template author) fine-grained control over the knowledge representation without touching the synthesis machinery. We observe in practice that this steering is effective for the phrasings we've tested, but acknowledge that formal phrasing stability analysis (measuring content overlap across paraphrased queries) is needed to validate this claim rigorously.
### 5.3 Template-Driven Agent Onboarding
Because knowledge pages are defined by synthesis queries, an entire agent's knowledge structure can be pre-configured via a declarative template:
```json
{
"mental_models": [
{
"id": "best-practices",
"name": "SEO Best Practices",
"source_query": "What are the SEO best practices for our content, combining industry standards with what has actually worked for us?",
"max_tokens": 4096,
"trigger": {
"refresh_after_consolidation": true,
"mode": "delta",
"exclude_mental_models": true,
"fact_types": ["observation"]
}
},
{"id": "performance", "source_query": "What strategies have worked...?"},
{"id": "preferences", "source_query": "What does the user prefer...?"}
]
}
```
Combined with reference document ingestion at setup time, an agent can begin its first session with pre-populated knowledge pages — synthesized from reference material, ready to evolve with user feedback. The template is the declarative specification; the system handles the imperative work. In our marketing-SEO demo, the agent received a 321-line SEO best practices document at setup, which was consolidated into the "SEO Best Practices" page. User feedback in subsequent sessions ("keep posts to 800 words", "comparison format works 3x better") was incorporated automatically — the page evolved from generic industry advice to personalized, data-backed rules.
### 5.4 Comparison with Memento
| Dimension | Memento | Our Approach |
|---|---|---|
| Who writes content | Agent (with judge + rollback) | System (consolidation + reflect) |
| Update timing | Synchronous (same turn) | Asynchronous (consolidation cycle) |
| Quality control | Judge LLM + unit tests | Synthesis query steering + observation filtering |
| Capture reliability | Agent must write | Deterministic hook |
| Infrastructure | Judge, test gate, rollback | Memory system + worker |
| Convergence | Formal via judge feedback loop | Empirically observed via accumulated observations |
Both approaches converge on the same insight: the agent needs persistent, evolving knowledge outside its context window. The key difference is where the write responsibility sits. Memento invests in making the agent a reliable writer (via safeguards); we avoid the problem entirely by making the agent read-only on content. We note that our convergence claim is empirical — we observe that page content stabilizes after sufficient observations as the observation set becomes representative — and lacks the formal guarantees that Memento provides through its judge feedback loop.
### 5.5 Is This a Third Paradigm?
One might argue that Approach 3 is merely "Approach 2 with agent-provided routing." We disagree. The critical difference is in the responsibility split:
- **Approach 2:** The system decides both *what to track* and *how to synthesize it*. The agent is entirely passive.
- **Approach 3:** The agent decides *what to track* (creates pages with synthesis queries). The system handles capture and synthesis.
The agent's role is minimal but critical — it provides the curation intelligence that pipelines lack (Section 4.2). Removing the agent from curation produces junk pages (we demonstrated this). Giving the agent full write responsibility produces unreliable persistence (we demonstrated this too). The minimal coupling — agent writes synthesis queries, system does everything else — is the contribution.
### 5.6 Failure Modes
**Overlapping pages.** If the agent creates two pages with overlapping synthesis queries (e.g., "user preferences for tone" and "editorial style preferences"), both will be synthesized independently and may contain redundant content. The agent skill instructs "prefer fewer broader pages" and to check existing pages before creating new ones. At current scale (3-10 pages) this has not been a problem; at scale, a lint step (per Karpathy's pattern [1]) that detects overlap and suggests merges would be needed.
**Contradicting observations.** When the user changes their mind ("actually, make posts 1200 words, not 800"), both observations exist in the bank. The synthesis step has temporal awareness — observations carry timestamps — and the synthesis query can steer resolution (e.g., "prefer recent feedback"). In practice with delta mode, only the new observation is processed, and it updates/overrides the existing page content. We have not formally tested contradiction resolution quality.
**Poorly phrased synthesis queries.** If a synthesis query is poorly phrased, the page produces junk. There is currently no feedback loop that surfaces this to the agent or user — the page silently contains irrelevant content. A quality signal (e.g., flagging pages never referenced by the agent) could detect this, analogous to Karpathy's lint operation. This is an acknowledged gap.
**Adversarial content.** If a user injects malicious content into a conversation, it gets retained, extracted as an observation, and potentially synthesized into a page. The system has no adversarial filter beyond the synthesis LLM's own safety mechanisms and observation-type scoping (pages can be configured to only synthesize from certain fact types). This is a general problem for all agent memory systems.
**Unbounded observation accumulation.** Observations accumulate indefinitely. Delta mode mitigates the cost problem (only new observations are processed per refresh) but storage grows linearly. Ebbinghaus-inspired decay (per MemoryBank [9]), observation merging, or archival strategies are natural extensions but are not implemented.
### 5.7 Privacy and Deletion
Raw conversation transcripts, extracted observations, and synthesized pages form a derivation chain. Deleting a conversation removes the source. Deleting an observation removes the extracted fact. But if the observation was already synthesized into a page, the page content retains the information until the next refresh. Full deletion requires: delete the observation, then trigger a full-mode refresh of affected pages to re-synthesize without the deleted content. This cascade is analogous to GDPR's "right to erasure" applied to materialized views — a known hard problem. Automated cascade triggers are future work.
### 5.8 Cost Analysis
Rough cost analysis using a lightweight model (Gemini 2.5 Flash Lite):
- **Retain** (fact extraction): ~3k tokens per session (~$0.001)
- **Consolidation** (observation extraction): ~5k tokens per session (~$0.002)
- **Page refresh** (reflect): ~4k tokens per page (~$0.002)
- **Per-agent per-day** (5 sessions, 3 pages): ~$0.02
The dominant cost is page refresh (one LLM call per page per consolidation cycle). Delta mode reduces this by scoping to new observations only. At $0.02/agent/day, the cost is negligible for production deployment — less than $1/month per agent.
## 6. Limitations and Future Work
1. **Consolidation latency.** The async cycle means knowledge pages are always slightly stale. For time-sensitive decisions, the agent must rely on direct conversation context rather than pages. Characterizing the latency distribution (P50/P95/P99) under various load conditions is needed.
2. **Observation extraction quality.** The pipeline LLM that extracts observations from conversations can miss nuance or extract irrelevant facts. A formal faithfulness study — measuring precision, recall, and fabrication rate on a labeled conversation set — is needed to characterize this bottleneck.
3. **Scale.** With N pages, each consolidation triggers N reflect calls. Delta mode mitigates this (only processing new observations) but the cost grows linearly. Scaling behavior at 10k+ observations and 50+ pages is untested. Batching or selective refresh (only refresh pages whose scope matches new observations) would help.
4. **Provenance.** Currently, pages are synthesized text with no per-statement attribution. The reflect response includes a `based_on` field listing source observations, but doesn't link specific statements to specific observations. Adding per-statement citations would enable the agent to trace *why* a knowledge page says what it says. The delta-mode structured operations (which produce typed edits rather than free text) are the foundation for this.
5. **Cross-agent knowledge sharing.** User-level preferences (timezone, communication style) apply across all agents but currently live in per-agent memory banks. A shared knowledge layer or cross-bank reference mechanism would avoid duplication.
6. **Forgetting.** Observations accumulate indefinitely. A principled forgetting mechanism — whether time-based decay [9], relevance-based pruning, or archival — is needed for long-running agents.
7. **Synthesis query stability.** We claim the synthesis query provides "steerable" control over page content but have not formally tested stability across paraphrased queries. An ablation measuring content overlap across query variants is needed.
8. **End-to-end quality benchmark.** The most important missing evidence: a controlled comparison of preference adherence across sessions for all three approaches (agent-maintained, pipeline-maintained, hybrid) on a standardized benchmark.
9. **Delta mode quality over long horizons.** Delta mode is asserted as preferable but we have not measured quality degradation over many consolidation cycles compared to full mode.
## 7. Conclusion
We demonstrate that self-learning LLM agents require a separation of concerns between knowledge capture, knowledge curation, and knowledge synthesis. The agent is an excellent reader and a capable curator (deciding what to track) but an unreliable writer (executing post-response persistence). By delegating capture to deterministic infrastructure and synthesis to asynchronous background processing, we achieve deterministic capture of all completed sessions and high-quality knowledge pages without burdening the agent's critical path.
The synthesis query abstraction — a declarative question that the system re-answers on every consolidation cycle — provides a clean interface between agent intent and system execution. The agent controls *what* gets synthesized; the system handles *when*, *how*, and *from what*.
This architecture is implemented and deployed on the OpenClaw agent platform with the Hindsight memory system. It is in active use with marketing, news feed, and development agents, demonstrating practical viability across diverse agent types and use cases.
## References
[1] Karpathy, A. "How I use LLMs." Blog post, karpathy.ai, April 2025. Describes the LLM Wiki pattern: raw sources → LLM-maintained wiki with three operations (ingest, query, lint).
[2] Jiang, Y. et al. "Memento: Empowering LLM Agents to Iteratively Self-Evolve via Read-Write Reflective Learning." arXiv:2503.18743, March 2025.
[3] Packer, C., Wooders, S., Lin, K., Fang, V., Patil, S.G., Stoica, I., Gonzalez, J.E. "MemGPT: Towards LLMs as Operating Systems." arXiv:2310.08560, 2023.
[4] Shinn, N., Cassano, F., Gopinath, A., Narasimhan, K., Yao, S. "Reflexion: Language Agents with Verbal Reinforcement Learning." NeurIPS 2023. arXiv:2303.11366.
[5] Wang, G., Xie, Y., Jiang, Y., Mandlekar, A., Xiao, C., Zhu, Y., Fan, L., Anandkumar, A. "Voyager: An Open-Ended Embodied Agent with Large Language Models." arXiv:2305.16291, 2023.
[6] Sumers, T.R. et al. "Cognitive Architectures for Language Agents (CoALA)." arXiv:2309.02427, 2023.
[7] Zhou, A. et al. "Language Agent Tree Search Unifies Reasoning Acting and Planning in Language Models." arXiv:2310.04406, 2023.
[8] Park, J.S. et al. "Generative Agents: Interactive Simulacra of Human Behavior." UIST 2023.
[9] Zhong, W. et al. "MemoryBank: Enhancing Large Language Models with Long-Term Memory." AAAI 2024.
[10] Xu, Z. et al. "A-MEM: Agentic Memory for LLM Agents." arXiv, 2025.
+812
View File
@@ -0,0 +1,812 @@
{
"type": "excalidraw",
"version": 2,
"source": "hindsight-paper",
"elements": [
{
"id": "title",
"type": "text",
"x": 280,
"y": 20,
"width": 500,
"height": 35,
"text": "Self-Learning Agent Loop",
"fontSize": 28,
"fontFamily": 1,
"textAlign": "center",
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 1,
"version": 1,
"versionNonce": 1,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "agent-box",
"type": "rectangle",
"x": 50,
"y": 100,
"width": 220,
"height": 160,
"strokeColor": "#1971c2",
"backgroundColor": "#d0ebff",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 2,
"version": 1,
"versionNonce": 2,
"isDeleted": false,
"roundness": {"type": 3},
"locked": false,
"link": null,
"updated": 1
},
{
"id": "agent-title",
"type": "text",
"x": 100,
"y": 115,
"width": 120,
"height": 25,
"text": "🤖 Agent",
"fontSize": 20,
"fontFamily": 1,
"textAlign": "center",
"strokeColor": "#1971c2",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 3,
"version": 1,
"versionNonce": 3,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "agent-reads",
"type": "text",
"x": 70,
"y": 150,
"width": 180,
"height": 100,
"text": "① Reads pages\n② Creates pages\n (synthesis queries)\n③ Recalls memories",
"fontSize": 14,
"fontFamily": 1,
"textAlign": "left",
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 4,
"version": 1,
"versionNonce": 4,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "user-box",
"type": "rectangle",
"x": 50,
"y": 320,
"width": 220,
"height": 80,
"strokeColor": "#2f9e44",
"backgroundColor": "#d8f5a2",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 5,
"version": 1,
"versionNonce": 5,
"isDeleted": false,
"roundness": {"type": 3},
"locked": false,
"link": null,
"updated": 1
},
{
"id": "user-title",
"type": "text",
"x": 90,
"y": 335,
"width": 140,
"height": 50,
"text": "👤 User\nPreferences, feedback,\nanalytics data",
"fontSize": 14,
"fontFamily": 1,
"textAlign": "center",
"strokeColor": "#2f9e44",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 6,
"version": 1,
"versionNonce": 6,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "capture-box",
"type": "rectangle",
"x": 380,
"y": 100,
"width": 220,
"height": 100,
"strokeColor": "#e8590c",
"backgroundColor": "#ffe8cc",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 7,
"version": 1,
"versionNonce": 7,
"isDeleted": false,
"roundness": {"type": 3},
"locked": false,
"link": null,
"updated": 1
},
{
"id": "capture-title",
"type": "text",
"x": 400,
"y": 115,
"width": 180,
"height": 75,
"text": "⚡ Capture\n(deterministic hook)\n\nRetains every conversation\nNo LLM decision point",
"fontSize": 13,
"fontFamily": 1,
"textAlign": "center",
"strokeColor": "#e8590c",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 8,
"version": 1,
"versionNonce": 8,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "consolidation-box",
"type": "rectangle",
"x": 380,
"y": 260,
"width": 220,
"height": 100,
"strokeColor": "#9c36b5",
"backgroundColor": "#f3d9fa",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 9,
"version": 1,
"versionNonce": 9,
"isDeleted": false,
"roundness": {"type": 3},
"locked": false,
"link": null,
"updated": 1
},
{
"id": "consolidation-title",
"type": "text",
"x": 400,
"y": 275,
"width": 180,
"height": 75,
"text": "🔄 Consolidation\n(async background)\n\nExtracts structured\nobservations from content",
"fontSize": 13,
"fontFamily": 1,
"textAlign": "center",
"strokeColor": "#9c36b5",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 10,
"version": 1,
"versionNonce": 10,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "pages-box",
"type": "rectangle",
"x": 700,
"y": 100,
"width": 260,
"height": 260,
"strokeColor": "#1098ad",
"backgroundColor": "#c3fae8",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 11,
"version": 1,
"versionNonce": 11,
"isDeleted": false,
"roundness": {"type": 3},
"locked": false,
"link": null,
"updated": 1
},
{
"id": "pages-title",
"type": "text",
"x": 740,
"y": 110,
"width": 180,
"height": 25,
"text": "📄 Knowledge Pages",
"fontSize": 18,
"fontFamily": 1,
"textAlign": "center",
"strokeColor": "#1098ad",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 12,
"version": 1,
"versionNonce": 12,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "page1",
"type": "rectangle",
"x": 720,
"y": 145,
"width": 220,
"height": 55,
"strokeColor": "#1098ad",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 13,
"version": 1,
"versionNonce": 13,
"isDeleted": false,
"roundness": {"type": 3},
"locked": false,
"link": null,
"updated": 1
},
{
"id": "page1-text",
"type": "text",
"x": 730,
"y": 150,
"width": 200,
"height": 44,
"text": "SEO Best Practices\nquery: \"What are the best\npractices, preferring our data...\"",
"fontSize": 11,
"fontFamily": 1,
"textAlign": "left",
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 14,
"version": 1,
"versionNonce": 14,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "page2",
"type": "rectangle",
"x": 720,
"y": 210,
"width": 220,
"height": 55,
"strokeColor": "#1098ad",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 15,
"version": 1,
"versionNonce": 15,
"isDeleted": false,
"roundness": {"type": 3},
"locked": false,
"link": null,
"updated": 1
},
{
"id": "page2-text",
"type": "text",
"x": 730,
"y": 215,
"width": 200,
"height": 44,
"text": "Content Performance\nquery: \"What strategies have\nperformed well or poorly...\"",
"fontSize": 11,
"fontFamily": 1,
"textAlign": "left",
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 16,
"version": 1,
"versionNonce": 16,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "page3",
"type": "rectangle",
"x": 720,
"y": 275,
"width": 220,
"height": 55,
"strokeColor": "#1098ad",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 17,
"version": 1,
"versionNonce": 17,
"isDeleted": false,
"roundness": {"type": 3},
"locked": false,
"link": null,
"updated": 1
},
{
"id": "page3-text",
"type": "text",
"x": 730,
"y": 280,
"width": 200,
"height": 44,
"text": "Editorial Preferences\nquery: \"What does the user\nprefer for tone, length...\"",
"fontSize": 11,
"fontFamily": 1,
"textAlign": "left",
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 18,
"version": 1,
"versionNonce": 18,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "arrow-agent-to-capture",
"type": "arrow",
"x": 270,
"y": 150,
"width": 105,
"height": 0,
"points": [[0, 0], [105, 0]],
"strokeColor": "#e8590c",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 19,
"version": 1,
"versionNonce": 19,
"isDeleted": false,
"roundness": {"type": 2},
"locked": false,
"link": null,
"updated": 1,
"startArrowhead": null,
"endArrowhead": "arrow"
},
{
"id": "arrow-agent-to-capture-label",
"type": "text",
"x": 285,
"y": 130,
"width": 80,
"height": 18,
"text": "conversation",
"fontSize": 12,
"fontFamily": 1,
"textAlign": "center",
"strokeColor": "#e8590c",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 20,
"version": 1,
"versionNonce": 20,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "arrow-capture-to-consolidation",
"type": "arrow",
"x": 490,
"y": 200,
"width": 0,
"height": 55,
"points": [[0, 0], [0, 55]],
"strokeColor": "#9c36b5",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 21,
"version": 1,
"versionNonce": 21,
"isDeleted": false,
"roundness": {"type": 2},
"locked": false,
"link": null,
"updated": 1,
"startArrowhead": null,
"endArrowhead": "arrow"
},
{
"id": "arrow-capture-to-consolidation-label",
"type": "text",
"x": 500,
"y": 220,
"width": 80,
"height": 18,
"text": "raw content",
"fontSize": 12,
"fontFamily": 1,
"textAlign": "left",
"strokeColor": "#9c36b5",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 22,
"version": 1,
"versionNonce": 22,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "arrow-consolidation-to-pages",
"type": "arrow",
"x": 600,
"y": 310,
"width": 95,
"height": -60,
"points": [[0, 0], [95, -60]],
"strokeColor": "#1098ad",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 23,
"version": 1,
"versionNonce": 23,
"isDeleted": false,
"roundness": {"type": 2},
"locked": false,
"link": null,
"updated": 1,
"startArrowhead": null,
"endArrowhead": "arrow"
},
{
"id": "arrow-consolidation-to-pages-label",
"type": "text",
"x": 610,
"y": 270,
"width": 90,
"height": 18,
"text": "observations →\nrefresh pages",
"fontSize": 11,
"fontFamily": 1,
"textAlign": "center",
"strokeColor": "#1098ad",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 24,
"version": 1,
"versionNonce": 24,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "arrow-pages-to-agent",
"type": "arrow",
"x": 700,
"y": 115,
"width": 410,
"height": 310,
"points": [[0, 0], [-20, -40], [-410, -40], [-410, 310], [-370, 310]],
"strokeColor": "#1971c2",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 25,
"version": 1,
"versionNonce": 25,
"isDeleted": false,
"roundness": {"type": 2},
"locked": false,
"link": null,
"updated": 1,
"startArrowhead": null,
"endArrowhead": "arrow"
},
{
"id": "arrow-pages-to-agent-label",
"type": "text",
"x": 310,
"y": 410,
"width": 200,
"height": 35,
"text": "reads at next session startup\n(synthesized, up-to-date knowledge)",
"fontSize": 12,
"fontFamily": 1,
"textAlign": "center",
"strokeColor": "#1971c2",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 26,
"version": 1,
"versionNonce": 26,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
},
{
"id": "arrow-user-to-agent",
"type": "arrow",
"x": 160,
"y": 320,
"width": 0,
"height": -55,
"points": [[0, 0], [0, -55]],
"strokeColor": "#2f9e44",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 27,
"version": 1,
"versionNonce": 27,
"isDeleted": false,
"roundness": {"type": 2},
"locked": false,
"link": null,
"updated": 1,
"startArrowhead": "arrow",
"endArrowhead": "arrow"
},
{
"id": "key-insight-box",
"type": "rectangle",
"x": 50,
"y": 460,
"width": 910,
"height": 70,
"strokeColor": "#868e96",
"backgroundColor": "#f8f9fa",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 28,
"version": 1,
"versionNonce": 28,
"isDeleted": false,
"roundness": {"type": 3},
"locked": false,
"link": null,
"updated": 1
},
{
"id": "key-insight-text",
"type": "text",
"x": 70,
"y": 472,
"width": 870,
"height": 45,
"text": "Key insight: Agent decides WHAT to track (creates pages with synthesis queries).\nSystem handles CAPTURE (deterministic hook) and SYNTHESIS (async consolidation + page refresh).\nThe agent never writes page content — it's read-only on knowledge. The system keeps pages current.",
"fontSize": 13,
"fontFamily": 1,
"textAlign": "left",
"strokeColor": "#495057",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"roughness": 1,
"opacity": 100,
"angle": 0,
"groupIds": [],
"boundElements": null,
"seed": 29,
"version": 1,
"versionNonce": 29,
"isDeleted": false,
"roundness": null,
"locked": false,
"link": null,
"updated": 1
}
],
"appState": {
"viewBackgroundColor": "#ffffff",
"gridSize": null
},
"files": {}
}
+53
View File
@@ -0,0 +1,53 @@
graph LR
User["👤 User<br/><i>preferences, feedback,<br/>analytics data</i>"] <-->|"conversation"| Agent
RefDoc["📑 Reference Docs<br/><i>SEO best practices,<br/>style guides, playbooks</i><br/>(ingested at setup)"]
subgraph OpenClaw["OpenClaw"]
Agent["🤖 Marketing SEO Agent"]
Skill["agent-knowledge<br/>skill"]
Plugin["hindsight-agent<br/>plugin"]
end
subgraph HindsightAgent["hindsight-agent CLI"]
PagesCmd["pages list/create"]
RecallCmd["recall"]
end
subgraph Hindsight["Hindsight"]
Bank["Memory<br/>Bank"]
Consol["Consolidation"]
Obs["Observations<br/><i>''meta descriptions &lt;160 chars''</i><br/><i>''user wants 800 words''</i><br/><i>''comparison posts 3x traffic''</i><br/><i>''never use leverage''</i>"]
Reflect["Reflect"]
end
subgraph KP["Knowledge Pages"]
direction TB
MM1["<b>SEO Best Practices</b><br/><i>What are the best practices,<br/>preferring our data over<br/>industry advice?</i>"]
MM2["<b>Content Performance</b><br/><i>What strategies have<br/>performed well or poorly?<br/>Include specific numbers.</i>"]
MM3["<b>Editorial Preferences</b><br/><i>What does the user prefer<br/>for tone, voice, length?<br/>Include corrections.</i>"]
end
Agent --- Skill
Agent -->|"agent_end"| Plugin
Plugin -->|"1. retains conversation<br/>(deterministic, async)"| Bank
RefDoc -->|"ingested at setup<br/>(hindsight-agent --content)"| Bank
Bank -->|"2. extracts facts<br/>(background)"| Consol
Consol -->|"3. structured"| Obs
Obs -->|"4. re-runs each<br/>synthesis query"| Reflect
Reflect -->|"5. updates<br/>page content"| KP
Skill -->|"reads pages<br/>(session startup)"| PagesCmd
PagesCmd -->|"resolves<br/>agent → bank"| KP
Skill -.->|"creates pages"| PagesCmd
Skill -->|"ad-hoc search"| RecallCmd
RecallCmd --> Obs
style OpenClaw fill:#d0ebff,stroke:#1971c2,stroke-width:2px
style HindsightAgent fill:#ffe8cc,stroke:#e8590c,stroke-width:2px
style Hindsight fill:#f3d9fa,stroke:#9c36b5,stroke-width:2px
style KP fill:#c3fae8,stroke:#1098ad,stroke-width:2px
style User fill:#d8f5a2,stroke:#2f9e44,stroke-width:2px
style RefDoc fill:#fff3bf,stroke:#e67700,stroke-width:2px
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB