AkurAI Build
Menu

Public repository / 409fb930689e231969f33d5f0947e030c930a722

AkurAI-Notes / .plan/CODE-PATTERNS.md

Latest change f1d9b8cc6ff48113d891a2d95fd79b2c49f1f4ff - Initial plan and .plan directory by Claude Code

# Code Patterns — copy-from references

> Every IDE file path here is an absolute pointer into an existing, working
> AkurAI-Framework / `akurai-crm` codebase on this host. DeepSeek v4 Flash
> should open these, copy the structural shape, swap identifiers, and never
> re-implement from scratch.

## 1. Workspace + Cargo.toml

### 1a. Workspace root

- **Copy from:** `~/Projects/akurai-crm/Cargo.toml`
- **Swap:** `akurai-crm` → `akurai-notes`, crate members `crm-*` → `notes-*`,
  repository URL `https://github.com/olibuijr/akurai-crm` →
  `https://github.com/olibuijr/AkurAI-Notes`, **start at version `0.0.1`**.
- **Skeleton to emit:**
  ```toml
  [workspace]
  resolver = "2"
  members = ["crates/notes-core", "crates/notes-api", "crates/notes-cli", "crates/notes-mcp"]

  [workspace.package]
  version = "0.0.1"
  edition = "2021"
  license = "MIT"
  repository = "https://github.com/olibuijr/AkurAI-Notes"
  authors = ["Ólafur Búi Ólafsson"]

  [profile.release]
  opt-level = 3
  lto = "fat"
  codegen-units = 1
  panic = "abort"
  strip = true
  ```

### 1b. Per-crate Cargo.toml

Copy `~/Projects/akurai-crm/crates/crm-api/Cargo.toml` and `crm-cli/Cargo.toml`
verbatim, swapping `crm` → `notes`. Key crate-by-crate dependencies:

| Crate | Reference | Notes deps |
|-------|-----------|-----------|
| `notes-core` | `crm-core/Cargo.toml` | `akurai-json`, `akurai-schema` |
| `notes-api` | `crm-api/Cargo.toml` | `notes-core`, `akurai-http`, `akurai-json`, `akurai-router`, `akurai-collections`, `akurai-storage`, `akurai-vector`, `akurai-markdown`, `akurai-template` |
| `notes-cli` | `crm-cli/Cargo.toml` | `notes-core`, `notes-api`, `notes-mcp` (when MCP runs in-process), `akurai-http` |
| `notes-mcp` | `crm-mcp/Cargo.toml` | `notes-core`, `notes-api`, `akurai-http`, `akurai-json`, `akurai-storage` |

All path-include from `../../AkurAI-Framework/crates/<crate>`. **Zero external
crates anywhere in the workspace.** Validate with:
`cargo tree --workspace --edges all | rg -v 'AkurAI-Framework|akurai-|notes-' | rg -v '^\s*$'`

## 2. HTTP server start + dispatch

**Copy from:** `~/Projects/akurai-crm/crates/crm-cli/src/cmd_serve.rs` (66 lines, exact)

Only changes:
- `CrmState::new(&cfg.db, cfg.dir.clone())` → `NotesState::new(&cfg.db,
  cfg.dir.clone(), cfg.embed_url)`
- default port `8091` → `8099`
- welcome printout line says `AkurAI-Notes`

Everything else identical: `Server::bind` from `akurai_http::Server`, dispatch
loop, `method_matches`. **Do not re-invent the dispatch loop** — copy it.

## 3. CLI entrypoint

**Copy from:** `~/Projects/akurai-crm/crates/crm-cli/src/main.rs` (99 lines, exact)

Add one subcommand: `mcp` (forks the MCP server instead of the web server).
Subcommand dispatch shape:

```rust
match args.first().map(String::as_str) {
    Some("serve") => cmd_serve::run(parse_serve(&args[1..])?).map_err(|e| e.to_string()),
    Some("mcp")   => cmd_mcp::run(parse_mcp(&args[1..])?).map_err(|e| e.to_string()),
    Some("seed")  => cmd_seed::run(args.get(1).map(|s| s.as_str()).unwrap_or("notes.db")).map_err(|e| e.to_string()),
    Some("version") | Some("--version") | Some("-V") => { println!("akurai-notes {VERSION}"); Ok(()) }
    Some("help") | Some("--help") | Some("-h") | None => { print_help(); Ok(()) }
    Some(other) => Err(format!("unknown command '{other}' (try: akurai-notes help)")),
}
```

Default serve options:
- `--host 127.0.0.1`, `--port 8099`, `--dir site/frontend`, `--db notes.db`,
  `--embed-url http://127.0.0.1:8081/v1/embeddings`, `--embed-model
  qwen3-embedding-0.6b`.

## 4. Router setup

**Copy from:** `~/Projects/akurai-crm/crates/crm-api/src/router_setup.rs` (282 lines, exact)

Structural copy with these substitutions:

| CRM | Notes |
|-----|-------|
| `CrmState` | `NotesState` |
| `state.objects: Vec<ObjectMetadata>` | `state.objects: Vec<ObjectMetadata>` (notes + notebooks + tags) |
| Routes `/api/people`, `/api/companies`, … | Routes `/api/notes`, `/api/notebooks`, `/api/tags`, `/api/search`, `/api/timeline`, `/api/me` |
| Cookie name `crm_session` | Cookie name `notes_session` (also in `auth/session.rs`) |
| `handlers::static_file_route` | Same (copied verbatim) |

Insert signature for the new `notes_state`:

```rust
pub struct NotesState {
    pub db: Arc<Mutex<BTree>>,
    pub objects: Vec<ObjectMetadata>,
    pub frontend_dir: PathBuf,
    pub embed_url: Option<String>,
    pub embed_model: Option<String>,
    pub idp_cfg: IdpConfig,
}
```

## 5. Auth (IDP OIDC client + session)

### 5a. IDP client — port of `crm-api/src/auth/idp_client.rs` (174 lines)

**Copy from:** `~/Projects/akurai-crm/crates/crm-api/src/auth/idp_client.rs`

**Single required change:** stop hardcoding `client_id` /
`client_secret` / `redirect_uri` literals. Read from env (this is a quality
fix; CRM's hardcoding is acceptable for its small dev surface, NOT for Notes
which Ólafur wants to be production-shaped):

```rust
pub struct IdpConfig {
    pub issuer: String,        // https://auth.olibuijr.com
    pub client_id: String,
    pub client_secret: String,
    pub redirect_uri: String,  // https://akurai-notes.olibuijr.com/auth/callback
    pub allow: Vec<String>,    // ["olibuijr@olibuijr.com"]
}

impl IdpConfig {
    pub fn from_env() -> Self {
        let env = std::env::var;
        Self {
            issuer:        env("AKURAI_NOTES_IDP_ISSUER").unwrap_or_else(|_| "https://auth.olibuijr.com".into()),
            client_id:     env("AKURAI_NOTES_IDP_CLIENT_ID").unwrap_or_default(),
            client_secret: env("AKURAI_NOTES_IDP_CLIENT_SECRET").unwrap_or_default(),
            redirect_uri:  env("AKURAI_NOTES_IDP_REDIRECT").unwrap_or_else(|_| "https://akurai-notes.olibuijr.com/auth/callback".into()),
            allow:         env("AKURAI_NOTES_ALLOW").unwrap_or_else(|_| "olibuijr@olibuijr.com".into()).split(',').map(str::trim).map(String::from).collect(),
        }
    }
}
```

`exchange_code` body is the same shape — POST `${issuer}/token` with
`grant_type=authorization_code`, Basic auth header on
`client_id:client_secret`, then GET `${issuer}/userinfo` with
`Bearer ${access_token}`. The `user sub` + `email` come back. Reject if
`!allow.contains(&email)`.

The IDP host is on the same EC2 box (loopback `127.0.0.1:3500`), so the
hand-rolled `http_get` / `http_post` HTTP/1.1 helper in `idp_client.rs` is fine
to keep as-is. Note that `idp_client.rs` already has correct `base64_encode` +
`urlencode` std impls — do **not** bring in the `base64` crate.

### 5b. Session — port of `crm-api/src/auth/session.rs` (73 lines)

**Copy from:** `~/Projects/akurai-crm/crates/crm-api/src/auth/session.rs`

One change: the cookie name `crm_session=` → `notes_session=` (line ~49 in
`extract_session_id`). Everything else identical — same `_session:<id>` keyspace
in the B+tree, same JSON shape, same `RandomState`-based session id.

### 5c. Login route shapes

CRM exposes four handlers (`/auth/login`, `/auth/callback`, `/auth/logout`,
`/api/me`) — copy the same shape. `login_route` does the
302 with `state`+`nonce`+`scope=openid profile email`.

### 5d. Registering the Notes client in the IDP

The AkurAI-IDP exposes an admin REST API (bearer-token protected). Create the
Notes client before first login (one-off op documented in `E03`):

```bash
ssh akurai-mail <<'REMOTE'
  IDP_TOKEN=$(sudo grep '^IDP_ADMIN_TOKEN=' /etc/akurai-idp/idp.env | cut -d= -f2-)
  curl -sS -X POST http://127.0.0.1:3500/admin/clients \
    -H "Authorization: Bearer $IDP_TOKEN" -H "Content-Type: application/json" \
    -d '{"client_id":"akurai-notes","name":"AkurAI-Notes","redirect_uris":["https://akurai-notes.olibuijr.com/auth/callback"],"grant_types":["authorization_code","refresh_token"],"token_endpoint_auth_method":"client_secret_post"}'
REMOTE
```

The IDP will mint a `client_secret`; save it to
`/etc/akurai-notes/notes.env` under `AKURAI_NOTES_IDP_CLIENT_SECRET=…`. The
exact admin API surface should be cross-checked against
`~/Projects/akurai-idp/src/routes/admin.rs` before invoking — confirm the route
shape in IDP's `routes/admin.rs` first (one-line verification step in E03).

## 6. Autogenerated collections API

**Copy from:** `~/Projects/AkurAI-Framework/crates/cli/src/collections_api.rs`

If you want Notes' REST API to be generated from `site/backend/collections.toml`
rather than hand-rolled (preferred; matches Framework principle #5), read the
`collections_api` handler in the Framework's own CLI to see how the live demo
(`/api/collections/posts/records`) is mounted. The schema declares a field with
`embed = true` and the CLI takes care of embedding on insert + cosine ranking
on `?search`. **Notes will lean on this** — most of `notes-api/src/handlers/crud.rs`
can just call the Framework's helper for "list", "get", "create", "update",
"delete" with the collection name.

If a behaviour is missing from the Framework's helper (e.g. owner scoping by
`session.sub`), fork the helper into `notes-api/src/handlers/crud.rs` and add
the filter — the Framework intends subprojects to fork narrowly, never copy
whole crates.

## 7. Vector / embeddings integration

**Copy from:** `~/Projects/AkurAI-Framework/crates/vector/src/lib.rs` and
`.../client.rs` (already read).

The flow inside `notes-api/src/embed.rs` is:

```rust
use akurai_vector::{embed, encode, decode, cosine, rank, EmbedError};

pub fn embed_one(text: &str, url: &str, model: &str) -> Result<Vec<f32>, EmbedError> {
    embed(url, model, text)
}

pub fn store_embedding(db: &mut BTree, note_id: u64, vec: &[f32]) -> Result<(), String> {
    let key = format!("note_emb:{}", note_id);
    let bytes = encode(vec);  // little-endian f32 → Vec<u8>
    db.insert(key.as_bytes(), &bytes).map_err(|e| e.to_string())?;
    db.commit().map_err(|e| e.to_string())?;
    Ok(())
}

pub fn semantic_search(db: &mut BTree, query_vec: &[f32], k: usize) -> Vec<(u64, f32)> {
    let mut scored: Vec<(u64, f32)> = Vec::new();
    // iterate "note_emb:" prefix in the B+tree (see AGENTS_DATA.md range scan)
    for (key, val) in db.range_prefix(b"note_emb:") {
        let id = std::str::from_utf8(&key[b"note_emb:".len()..]).ok()?.parse::<u64>()?;
        if let Ok(doc) = decode(&val) {
            scored.push((id, cosine(query_vec, &doc)));
        }
    }
    rank(scored, k) // returns the top-k pairs, sorted desc
}
```

`range_prefix` is the B+tree prefix scan documented at
`~/Projects/AkurAI-Framework/AGENTS_DATA.md`. If the `BTree` API doesn't
already expose it as `range_prefix`, look for `range(..)` over a constructed
upper-bound (the CRM `router_setup.rs::upper_bound` helper is exactly this
pattern: append `0xff` to the prefix). **Do not iterate the whole DB** to
find `note_emb:*` keys — always range-scan with an upper bound.

## 8. MCP server

**Copy from:** `~/Projects/akurai-crm/crates/crm-mcp/src/server.rs` (61 lines, exact) and
`tools.rs` (520 lines — read first 80 lines to internalize tool_def shape).

The Notes tool registry swaps CRM tools for Notes tools. Same JSON-RPC
`tools/list` and `tools/call` shape. `handle_request` already parses the
JSON-RPC envelope — only the dispatch inside needs the notes tool set (see E06).

**Routes:** Only `POST /mcp` allowed; 404 otherwise. 405 on non-POST. Same as
CRM's `server.rs`.

**Auth:** Notes-MCP supports two auth tiers (pick one in E06 with Ólafur):
- **Cookie-only** — share `notes_session` with the web app (simplest for a
  browser-driven MCP client like Claude Desktop pointed at the URL with the
  cookie). Implementation: re-use `auth::session::extract_session_id` on the
  `/mcp` route.
- **Bearer + allowlist** — accept `Authorization: Bearer <static-key>` from
  env `AKURAI_NOTES_MCP_TOKEN`. For v0.0.1 this is the simple choice; the
  Anthropic MCP client supports `Authorization` headers natively.

## 9. SSR templates

**Copy from:** `~/Projects/AkurAI-Framework/site/frontend/header.html`,
`footer.html`, `index.html`, `login.html`, `account.html`, `signals.js`,
`islands.js`, `spa.js`, `styles.css`, `theme.js`, `themes.css`, `themes.json`.

The Framework ships the no-build frontend as vendored asset files. For Notes'
first cut, copy the header/footer/styles and theme machinery verbatim, then
write `index.html` (public landing) and `app.html` (logged-in shell) as new
templates that compose those primitives.

**Template syntax is `{{ x }}` escaped, `{{{ x }}}` raw, `{% if cond %}`,
`{% for x in xs %}`, `{% include "header" %}`.** Reference:
`~/Projects/AkurAI-Framework/AGENTS_FRONTEND.md`.

**The `app.js` readyState guard is NON-OPTIONAL** (see AGENTS_FRONTEND.md):
```js
if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", init);
} else {
  init();
}
```
Use this pattern on every auto-init module; never a bare `addEventListener("DOMContentLoaded", …)`.

## 10. Site backend config

**Copy from:** `~/Projects/akurai-crm/site/backend/{page.json, collections.toml, routes.json}`
· **Reference:** `~/Projects/AkurAI-Framework/site/backend/{page.json, collections.toml, routes.json}`
for the canonical Framework shape.

Notes' `page.json` minimal shape:
```json
{
  "site": { "title": "AkurAI-Notes", "description": "Private notes with semantic search",
    "url": "https://akurai-notes.olibuijr.com", "version": "v0.0.1" },
  "brand": { "name": "AkurAI-Notes", "tagline": "Notes you own. Search that finds.",
    "logo": "/icon-notes.png" },
  "nav": [
    { "label": "Notes",   "path": "/app",     "icon": "sticky-note" },
    { "label": "Search",  "path": "/app/search","icon": "search" },
    { "label": "Settings", "path": "/app/settings", "icon": "settings" },
    { "label": "Logout",  "path": "/auth/logout", "icon": "log-out" }
  ]
}
```

Notes' `collections.toml` minimal shape (E04 has the full version with `embed = true`):
```toml
[[collection]]
name = "notes"
  [[collection.field]] name = "title", type = "text", required = true, embed = true
  [[collection.field]] name = "body",  type = "text",                  embed = true
  [[collection.field]] name = "notebookId", type = "int"
  [[collection.field]] name = "ownerSub",   type = "text"
  [[collection.field]] name = "tags", type = "text"
  [[collection.field]] name = "createdAt", type = "int"
  [[collection.field]] name = "updatedAt", type = "int"
```
Routes are mounted automatically by `akurai-collections` from the TOML. The
`notes-cli` serves both the auto-API AND the SSR templates referenced in
`routes.json` — see `crm-cli/src/cmd_serve.rs` for the dual-mount pattern.

## 11. Release engine (deploy.sh)

**Copy from:** `~/Projects/AkurAI-Framework/deploy.sh` (full text)
**Do NOT copy** `~/Projects/akurai-crm/deploy.sh` (it's stripped; no
`akurai-ec2` publish).

Swap:

| Framework | Notes |
|-----------|-------|
| `REPO="olibuijr/AkurAI-Framework"` | `REPO="olibuijr/AkurAI-Notes"` |
| `site/content/changelog.md` site changelog | Drop this (Notes has no docs site); only cut `CHANGELOG.md` |
| `akurai-framework.olibuijr.com` | `akurai-notes.olibuijr.com` |
| Internal port `8094` | `8099` |
| Binary name `akurai` (workspace crate `cli`) | `akurai-notes` (workspace crate `notes-cli`) |
| `terraform -p akurai` | `-p akurai-notes` |
| `site` served dir | `site` (same — Notes serves its own `site/frontend`) |

Keep the `ec2` publish-only mode: `./deploy.sh ec2` re-publishes without bumping.

First run of `deploy.sh` does `gh repo create "$REPO" --private --source=. --remote=origin`
automatically (matches Framework deploy.sh), so Ólafur doesn't need to pre-create.

Lifecycle:
- Initial commit on `main` contains `VERSION=0.0.1`, `Cargo.toml [workspace.package] version = "0.0.1"`, an empty `## [Unreleased]\n` CHANGELOG.
- `./deploy.sh patch` → `0.0.2`, cuts `## [0.0.2] - <date>`.
- Pre-1.0 minor bumps are allowed to break (Framework convention); document each
  in a `### Breaking` subsection like the Framework.