Skip to content

Commit 7d74440

Browse files
authored
Merge pull request #40 from mindfulinteligence/dev
feat: optimize embeddings and add docs
2 parents 2cddcd0 + 235d270 commit 7d74440

19 files changed

Lines changed: 459 additions & 104 deletions

File tree

.codex/run-local.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
55
cd "$project_root"
66

77
set -a
8-
source .env
8+
[ ! -f .env ] || source .env
99
set +a
1010

11+
export INFISICAL_UNIVERSAL_AUTH_CLIENT_ID="$(<.opencode/infisical-client-id)"
12+
export INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET="$(<.opencode/infisical-client-secret)"
13+
1114
export CODEX_HOME="$project_root/.codex"
1215
exec codex "$@"

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Check if `@AGENTS_STEWARD.md` exists. If yes, follow the instructions there. If
88

99
**Save what you produce:** after coding tasks touching `/lib/`, propose or update the module spec (`specs_propose` purpose/invariants/workflows); after a repeatable procedure, save a skill (`skill_save`); after working out a plan with the user, save it as a document under `documents/plans/<slug>`; run `query_specs(undocumented: true)` to find modules missing specs. See the `steward_get_started` guidance packet for the store decision.
1010

11+
**Keep the public docs current:** `/docs` is compiled from `README.md`, `guides/`, and `priv/skills/steward-installer.md`. Any change to installation, behavior, configuration, deployment, or testing must update the matching source document in the same task.
12+
1113
## Guides
1214

1315
Project-specific workflows live in `guides/`. Check them before starting work:

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
55
**Website: [stewardacs.xyz](https://stewardacs.xyz)**
66

7+
**Documentation: [stewardacs.xyz/docs](https://stewardacs.xyz/docs)** — installation, configuration, development, deployment, and testing guides published from this repository.
8+
9+
New to Steward? Start with [what Steward is, how to connect agents, and how teams use it](guides/getting-started.md).
10+
711
Steward ACS is an **infrastructure layer** for multi-agent coordination. It runs as a standalone Phoenix web application (port 4001) and exposes MCP (Model Context Protocol) tools that any AI agent — Claude, GPT, Llama, or any MCP-compatible client — can call directly. It does not do the work itself; it manages the agents who do.
812

913
---
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Memory Embedding Performance
2+
3+
## Goal
4+
5+
Keep embedding backfills from monopolizing Ollama or keeping Neon active after
6+
every deployment while ensuring changed memories eventually receive the
7+
current model's embedding.
8+
9+
## Implementation
10+
11+
- Store a SHA-256 fingerprint of normalized memory text plus model name with
12+
every embedding.
13+
- Use a database anti-join to avoid loading the full embedding ID set into the
14+
application.
15+
- Embed up to 32 memories per bounded batch request and bulk upsert successes.
16+
- Delay the initial backfill by five seconds so application startup is not
17+
coupled to corpus size.
18+
- Reprocess rows with missing or stale fingerprints.
19+
20+
## Follow-Up
21+
22+
- Add a supervised queue with configurable rate limits and exponential retry
23+
backoff for very large tenants.
24+
- Add progress gauges for pending, completed, and quarantined embeddings.
25+
- Move scheduled backfills to a dedicated worker when corpus size exceeds the
26+
application compute budget.
27+
28+
## Verification
29+
30+
- Fingerprints are deterministic and change when source text or model changes.
31+
- Batch failures do not prevent successful rows in the same batch from being
32+
persisted.
33+
- Deployments run the backfill asynchronously after the configured delay.

guides/getting-started.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Steward: a shared operating system for AI agents
2+
3+
Steward helps people coordinate AI agents that work on the same projects.
4+
5+
Without coordination, two agents can edit the same file, repeat the same investigation, or make decisions without telling the rest of the team. Steward gives them one shared place to declare work, avoid collisions, and preserve what they learn.
6+
7+
Steward does **not** replace your coding agent, chat assistant, repository, or project tracker. It connects to agents through MCP and adds a coordination layer around the tools you already use.
8+
9+
## What Steward does
10+
11+
| Need | Steward capability | Result |
12+
|---|---|---|
13+
| Know who is doing what | Tasks and agent presence | People and agents can see active work before starting something new. |
14+
| Prevent conflicting edits | File locks | Agents avoid changing the same file at the same time. |
15+
| Stop rediscovering decisions | Memories | Durable decisions, warnings, and patterns follow the team. |
16+
| Keep code intent understandable | Specs | Agents see a module's purpose and invariants before changing it. |
17+
| Reuse proven procedures | Skills | Installation, deployment, debugging, and support workflows become repeatable. |
18+
| Keep long-form knowledge available | Documents | Plans, policies, briefs, and reference material remain searchable. |
19+
20+
## Who it is for
21+
22+
- A developer using more than one coding agent or editor.
23+
- A team whose agents work across the same repositories.
24+
- An organization that wants agent decisions and procedures to survive beyond one chat session.
25+
- Operators who need to see active work, review shared knowledge, and control access by organization and role.
26+
27+
For one short, isolated agent task, Steward may be unnecessary. It becomes useful when work overlaps, repeats, or needs to be handed between people and agents.
28+
29+
## Set up Steward
30+
31+
Choose one of these paths:
32+
33+
1. **Use a hosted organization:** create or join an organization from the Steward sign-in page. Your organization gets an isolated workspace and MCP endpoint.
34+
2. **Run Steward yourself:** follow the [installation guide](/docs/install). The default local setup uses Docker, SQLite, and no LLM provider.
35+
36+
After setup, open the Steward dashboard. It shows the MCP endpoint and copy-ready instructions for connecting chat and coding agents.
37+
38+
## Connect a coding agent
39+
40+
The exact settings screen differs by agent, but the flow is the same:
41+
42+
1. In the Steward dashboard, copy the **coding MCP endpoint** and coding-agent instructions.
43+
2. Add the endpoint as an MCP server in Codex, Cursor, Claude Code, OpenCode, or another MCP-compatible client.
44+
3. Complete the browser sign-in when using a hosted organization. A local installation uses its configured API key instead.
45+
4. Paste the coding-agent instructions into the repository's `AGENTS.md` or equivalent rules file.
46+
5. Restart or reconnect the agent so it discovers the Steward tools.
47+
6. Ask the agent to check Steward. A successful connection returns its identity, repository context, and current work guidance.
48+
49+
Example remote configuration:
50+
51+
```toml
52+
[mcp_servers.steward]
53+
url = "https://YOUR-STEWARD-HOST/mcp/sse"
54+
```
55+
56+
Keep the generated repository instructions in version control when they contain no secrets. Keep API keys and local environment files out of version control.
57+
58+
## Connect a chat agent
59+
60+
Use the dashboard's **chat MCP endpoint** and chat instructions when connecting a conversational assistant such as Claude or ChatGPT.
61+
62+
Chat agents get a smaller, safer tool surface for finding knowledge, reading skills and documents, checking work status, and saving approved information. Coding agents additionally receive task and file-locking tools because they modify repositories.
63+
64+
## The normal agent workflow
65+
66+
Once connected, agents should follow this loop:
67+
68+
1. **Check context:** identify the repository and load relevant memories, specs, and skills.
69+
2. **Claim work:** create or claim a task before making changes.
70+
3. **Lock files:** reserve the files that will be edited.
71+
4. **Do and verify the work:** change the project and run the smallest meaningful checks.
72+
5. **Save reusable knowledge:** update a spec, skill, document, or memory when the work produced something future agents need.
73+
6. **Release and report:** release the task and submit feedback so the next person or agent sees a clean state.
74+
75+
The human remains responsible for priorities, approvals, and decisions that change scope. Steward makes the agent's work visible; it does not grant agents authority they did not already have.
76+
77+
## How this helps a team
78+
79+
### Fewer collisions
80+
81+
Before editing, an agent can see that another agent already owns a task or file. The team spends less time resolving duplicate work and conflicting patches.
82+
83+
### Better handoffs
84+
85+
A task records the current unit of work. Specs explain why code exists. Memories preserve decisions and warnings. A different person or agent can continue without reconstructing the full history from chat transcripts.
86+
87+
### Consistent procedures
88+
89+
When a deployment, installation, or support workflow works, save it as a skill. Future agents retrieve the same verified steps instead of improvising a new procedure.
90+
91+
### Shared knowledge with boundaries
92+
93+
Organizations, teams, projects, roles, and authority levels control who can see or change shared information. Local ACS instances remain test-only; team knowledge belongs in the shared Steward organization.
94+
95+
### Human oversight
96+
97+
The dashboard shows active agents, tasks, memories, documents, skills, tool requests, and errors. Humans can review the operating context instead of relying on each agent to summarize itself accurately.
98+
99+
## A practical team rollout
100+
101+
1. Start with one repository and two or three people.
102+
2. Add the Steward MCP connection and repository instructions to every agent used on that repository.
103+
3. Require task claiming and file locking for all code changes.
104+
4. Save only durable knowledge: decisions, invariants, warnings, and repeatable procedures.
105+
5. Review memories, specs, and skills regularly; archive or correct anything stale.
106+
6. Expand to more repositories after the workflow feels routine.
107+
108+
The goal is not to store every conversation. The goal is to keep the small amount of context that prevents the team from repeating mistakes.
109+
110+
## Next steps
111+
112+
- [Install Steward](/docs/install)
113+
- [Review configuration and secrets](/docs/configuration)
114+
- [Understand remote versus local development](/docs/development)
115+
- [Deploy Steward](/docs/deployment)
116+
- [Read the technical reference](/docs/technical)

lib/acs/application.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ defmodule Acs.Application do
124124
# to avoid Ecto sandbox conflicts with background DB queries.
125125
if Application.get_env(:steward_acs, :start_background_workers, true) do
126126
Task.start(fn ->
127+
# Let the application become ready before a large corpus reaches Ollama.
128+
Process.sleep(Application.get_env(:steward_acs, :embedding_backfill_delay_ms, 5_000))
129+
127130
unless Acs.Org.multi_tenant?() do
128131
{:ok, count, quarantined} = Acs.Memory.Indexer.sync_all()
129132

lib/acs/memory/embedding.ex

Lines changed: 69 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ defmodule Acs.Memory.Embedding do
2424
# Texts per `/api/embed` request. Batching cuts HTTP round-trips and lets
2525
# Ollama process the array in a single model forward pass.
2626
@default_batch_chunk_size 8
27+
@default_backfill_batch_size 32
2728

2829
@doc """
2930
Returns the configured Ollama URL.
@@ -49,6 +50,17 @@ defmodule Acs.Memory.Embedding do
4950
|> Keyword.get(:dimensions, @default_dimensions)
5051
end
5152

53+
@doc """
54+
Returns a stable fingerprint for the source text and embedding model.
55+
56+
Embeddings are reusable until either input changes.
57+
"""
58+
@spec fingerprint(String.t(), String.t()) :: String.t()
59+
def fingerprint(text, model_name \\ model()) when is_binary(text) and is_binary(model_name) do
60+
:crypto.hash(:sha256, model_name <> "\0" <> text)
61+
|> Base.encode16(case: :lower)
62+
end
63+
5264
# Retrieval queries embedded against the corpus don't need the full text —
5365
# Ollama embedding cost scales ~linearly with input length, and claim-time
5466
# queries are long (title + full task description). Truncating the retrieval
@@ -470,9 +482,9 @@ defmodule Acs.Memory.Embedding do
470482
@doc """
471483
Generates embeddings for all memories that don't yet have one.
472484
473-
Queries acs_memories for all memory IDs, queries memory_embeddings for
474-
existing IDs, computes the difference, and generates embeddings for each
475-
missing memory. Skips memories with status "parse_error" or "rejected".
485+
Queries only memories whose content fingerprint is missing or stale, then
486+
embeds them in bounded batches. Skips memories with status "parse_error" or
487+
"rejected".
476488
477489
Returns `{:ok, stats}` where stats is a map:
478490
`%{total: N, existing: N, embedded: N, failed: N}`
@@ -518,44 +530,34 @@ defmodule Acs.Memory.Embedding do
518530
# Ensure embeddings table exists before querying it
519531
VectorIndex.create_embeddings_table()
520532

521-
# 1. Get all memory IDs from acs_memories
522-
all_memory_ids =
523-
Repo.all(from m in Schema, select: m.id)
524-
|> MapSet.new()
525-
526-
total = MapSet.size(all_memory_ids)
527-
528-
# 2. Get all memory IDs from memory_embeddings
529-
embedded_ids =
530-
case Repo.query("SELECT memory_id FROM memory_embeddings") do
531-
{:ok, %{rows: rows}} ->
532-
rows |> Enum.map(fn [id] -> id end) |> MapSet.new()
533-
534-
{:error, _} ->
535-
# Table might not exist yet — treat as empty
536-
MapSet.new()
537-
end
538-
539-
existing = MapSet.size(embedded_ids)
540-
541-
# 3. Find memories without embeddings
542-
missing_ids = MapSet.difference(all_memory_ids, embedded_ids)
533+
model_name = model()
543534

544-
# 4. Load full memories and filter out parse_error/rejected
535+
# Keep the candidate query in the database. Existing rows are included only
536+
# when their fingerprint or model is missing/stale.
545537
memories_to_embed =
546-
missing_ids
547-
|> MapSet.to_list()
548-
|> then(fn ids ->
549-
case ids do
550-
[] -> []
551-
_ -> Repo.all(from m in Schema, where: m.id in ^ids)
552-
end
538+
Repo.all(
539+
from m in Schema,
540+
left_join: e in "memory_embeddings",
541+
on:
542+
e.org == m.org and
543+
(e.memory_id == m.id or
544+
e.memory_id == fragment("? || ':' || ?", m.org, m.id)),
545+
where:
546+
m.status not in ["parse_error", "rejected"] and
547+
m.kind in ^embeddable_kinds(),
548+
select: {m, e.content_hash, e.embedding_model}
549+
)
550+
|> Enum.filter(fn {schema, content_hash, embedding_model} ->
551+
expected_hash = schema_to_retrieval_text(schema) |> fingerprint(model_name)
552+
content_hash != expected_hash or embedding_model != model_name
553553
end)
554-
|> Enum.reject(fn m -> m.status in ~w(parse_error rejected) end)
555-
|> Enum.reject(fn m -> !(m.kind in embeddable_kinds()) end)
554+
|> Enum.map(fn {schema, _content_hash, _embedding_model} -> schema end)
556555

557-
batch_size = 10
558-
{embedded_count, failed_count} = embed_in_batches(memories_to_embed, batch_size, 0, 0)
556+
total = Repo.aggregate(Schema, :count, :id)
557+
existing = max(total - length(memories_to_embed), 0)
558+
559+
{embedded_count, failed_count} =
560+
embed_in_batches(memories_to_embed, @default_backfill_batch_size)
559561

560562
stats = %{
561563
total: total,
@@ -571,55 +573,44 @@ defmodule Acs.Memory.Embedding do
571573
{:ok, stats}
572574
end
573575

574-
defp embed_in_batches([], _batch_size, embedded, failed), do: {embedded, failed}
575-
576-
defp embed_in_batches(memories, batch_size, embedded, failed) do
577-
{batch, rest} = Enum.split(memories, batch_size)
578-
579-
{batch_embedded, batch_failed, successes} =
580-
Enum.reduce(batch, {0, 0, []}, fn schema, {emb_acc, fail_acc, ok} ->
581-
case embed_single_memory(schema) do
582-
{:ok, embedding} -> {emb_acc + 1, fail_acc, [{schema, embedding} | ok]}
583-
:error -> {emb_acc, fail_acc + 1, ok}
584-
end
585-
end)
586-
587-
alias Acs.Memory.VectorIndex
588-
589-
if successes != [] do
590-
VectorIndex.upsert_embeddings(
591-
Enum.map(successes, fn {schema, embedding} ->
592-
{schema.id, embedding, schema.org, schema.repo, schema.origin}
576+
defp embed_in_batches(memories, batch_size) do
577+
memories
578+
|> Enum.chunk_every(batch_size)
579+
|> Enum.reduce({0, 0}, fn batch, {embedded, failed} ->
580+
texts = Enum.map(batch, &schema_to_retrieval_text/1)
581+
results = embed_batch(texts)
582+
583+
{successes, batch_failed} =
584+
batch
585+
|> Enum.zip(texts)
586+
|> Enum.zip(results)
587+
|> Enum.reduce({[], 0}, fn
588+
{{schema, text}, {:ok, embedding}}, {ok, failed_count} ->
589+
{[{schema, embedding, fingerprint(text), model()} | ok], failed_count}
590+
591+
{{schema, _text}, {:error, reason}}, {ok, failed_count} ->
592+
Logger.warning("[Embedding] Failed to embed memory #{schema.id}: #{reason}")
593+
{ok, failed_count + 1}
593594
end)
594-
)
595-
end
596595

597-
# Sleep between batches to avoid overwhelming Ollama
598-
if rest != [] do
599-
Process.sleep(500)
600-
end
596+
if successes != [] do
597+
Acs.Memory.VectorIndex.upsert_embeddings(
598+
Enum.map(successes, fn {schema, embedding, content_hash, embedding_model} ->
599+
{schema.id, embedding, schema.org, schema.repo, schema.origin, content_hash,
600+
embedding_model}
601+
end)
602+
)
603+
end
601604

602-
embed_in_batches(rest, batch_size, embedded + batch_embedded, failed + batch_failed)
605+
{embedded + length(successes), failed + batch_failed}
606+
end)
603607
end
604608

605-
defp embed_single_memory(schema) do
609+
defp schema_to_retrieval_text(schema) do
606610
alias Acs.Memory.Indexer
607611

608-
# Convert schema to Memory struct
609612
attrs = Indexer.schema_to_memory_attrs(schema)
610613
memory = Acs.Memory.new(attrs)
611-
612-
# Generate retrieval text
613-
retrieval_text = memory_to_retrieval_text(memory)
614-
615-
# Generate embedding
616-
case embed_text(retrieval_text) do
617-
{:ok, embedding} ->
618-
{:ok, embedding}
619-
620-
{:error, reason} ->
621-
Logger.warning("[Embedding] Failed to embed memory #{memory.id}: #{reason}")
622-
:error
623-
end
614+
memory_to_retrieval_text(memory)
624615
end
625616
end

0 commit comments

Comments
 (0)