lens: name the agents, open a door, and move notes
The switcher was a native select of nineteen rows, four of them labelled by eight hex characters — every name was in a cadence's agent.json all along. Observe opens for five minutes on a written reason plus the lockscreen credential. polkit and sudo both answered "authorized" here with no prompt at all, so neither holds the gate; writes commit as "amend (human)" in her own history. Notes move between folders, and a rename takes its links with it.
This commit is contained in:
parent
1eb2840df7
commit
216faea032
7 changed files with 1548 additions and 137 deletions
114
DESIGN.md
114
DESIGN.md
|
|
@ -28,11 +28,90 @@ history, because a memory written a commit at a time is only honestly read
|
|||
that way.
|
||||
|
||||
That asymmetry is enforced in the process, not the page. `Mode::Tend` allows
|
||||
`save`, `create` and `sync`; `Mode::Observe` refuses all three by name before
|
||||
the op reaches the vault. A surface that merely hides a button has gated
|
||||
`save`, `create`, `move` and `sync`; `Mode::Observe` refuses all four by name
|
||||
before the op reaches the vault. A surface that merely hides a button has gated
|
||||
nothing — and `saf/identity/02-agent-principal.md` is explicit that readable
|
||||
does not mean owned.
|
||||
|
||||
## The door into the cloister
|
||||
|
||||
The human owns the machine, so the refusal was never a permission — the files
|
||||
are his uid, and `$EDITOR` was always one command away. What the read-only mode
|
||||
actually bought was that a reach into her memory could not happen *by accident*,
|
||||
in the same motion as tending his own garden. Keeping that and adding no door
|
||||
was the wrong trade: it moved the amendment out of the lens, where it leaves no
|
||||
mark she can read, and into a text editor, where it looks exactly like her own
|
||||
writing in her own history.
|
||||
|
||||
So the door exists, it is loud, and it signs the work:
|
||||
|
||||
- **A written reason, first.** Refused if empty. The doctrine's break-glass
|
||||
grant has the same rule for the same purpose — a reach you cannot explain is
|
||||
one you should not be making.
|
||||
- **A live credential, second.** `souveraine-stepup`, the shell's own PAM
|
||||
service, which "must accept exactly the credentials the lockscreen accepts,
|
||||
no more". Not polkit and not sudo: on this laptop, on 2026-08-24, both
|
||||
answered *authorized* for `org.souveraine.stepup` with no prompt of any kind
|
||||
— polkit in 3.6s through an agent nobody saw, sudo from a live timestamp.
|
||||
A ceremony that never asks is the formality `saf/identity/02-agent-principal.md`
|
||||
names by that word. libpam is `dlopen`ed rather than linked, so a machine
|
||||
without it still reads every tree and refuses only the amend.
|
||||
- **A grant that expires.** Five minutes, in memory, one tree at a time, the
|
||||
shell's `StepUpAuth` default. Revoked on flip, on relinquish, on expiry, and
|
||||
with the process. Leaving her memory gives the hand back.
|
||||
- **`sync` stays refused, grant or no grant.** Amending her memory is one act;
|
||||
pushing her archive to a remote under the human's credentials is another, and
|
||||
the ceremony bought only the first. Her own sync carries the amendment out.
|
||||
- **The archive says so.** Every write under a grant commits as
|
||||
`amend (human): …` with the reason in the body. She reads her own history;
|
||||
that line is how she finds out, and the lens's history strip is where the
|
||||
human sees the same thing from the other side.
|
||||
|
||||
The band above the graph changes colour and stops saying *guest*. The two
|
||||
states must not be mistakable for one another at a glance.
|
||||
|
||||
## The grammar of linking
|
||||
|
||||
`[[wikilinks]]` are the whole grammar and that is deliberate, but a vault drifts
|
||||
in a specific way: the thought connected two notes and the file never said so.
|
||||
The prior art converges on that gap from different directions.
|
||||
|
||||
- **Zettelkasten proper** put the structure in the *name* — Luhmann's `1a1`
|
||||
folgezettel encodes where a note sits in a line of argument. Precise, and it
|
||||
asks a person to know the shape before they write.
|
||||
- **Obsidian** keeps the names free and pays for it with two panes: backlinks,
|
||||
and **unlinked mentions** — notes whose title appears in this note's prose
|
||||
without a link. The second is what turns a pile back into a graph, and it is
|
||||
the single most copied idea in the space.
|
||||
- **Roam and Logseq** went below the note: block references and transclusion,
|
||||
where the unit of linking is a line rather than a file. Powerful, and it makes
|
||||
the markdown stop being plain markdown, which this vault will not trade.
|
||||
- **Dendron and Foam** put the hierarchy back in the filename
|
||||
(`proj.souveraine.lens`), replacing folders with a naming convention.
|
||||
- **Andy Matuschak's evergreen notes** argue the opposite of all of it: title
|
||||
the note as an API, link densely, and let folders wither.
|
||||
- **Dataview and Logseq properties** add *typed* links — `[[supports::x]]`,
|
||||
`[[refutes::y]]` — so the edge carries a claim, not just an adjacency.
|
||||
|
||||
What the lens takes:
|
||||
|
||||
- **Unlinked mentions, built.** Titles of four characters or more that appear in
|
||||
this note's prose on a word boundary, unlinked, longest first, six at most.
|
||||
One tap rewrites the first occurrence in place and commits it. This is the
|
||||
answer to "systematically linking": the system finds the edge the writing
|
||||
already implied and asks for one keystroke.
|
||||
- **A link indicator, built.** The drawer head carries `n in · n out · n loose`,
|
||||
so the note's standing in the graph is legible before scrolling. A dead
|
||||
`[[link]]` wears a `⊕` where it stands — an invitation, not an error.
|
||||
- **Link-preserving moves, built.** A rename rewrites `[[old-stem]]` across the
|
||||
tree in the same commit, aliases and `#anchors` intact. A link that named the
|
||||
note's *title* still resolves and is left alone.
|
||||
- **Typed links, not built.** They change the grammar every note is written in
|
||||
from here on, which is the human's call and not the lens's. If they land, they
|
||||
land as `[[verb::target]]` parsed alongside the plain form, with the verb
|
||||
colouring the edge on the canvas — the graph is where a typed link would
|
||||
finally pay for itself.
|
||||
|
||||
Consequences, held as rules:
|
||||
|
||||
- The lens never syncs the garden's notes into the SouveraineOS documentation
|
||||
|
|
@ -72,10 +151,10 @@ document; every piece of data arrives over the IPC bridge and the bridge is
|
|||
the only conversation:
|
||||
|
||||
- Page → process: `window.ipc.postMessage({id, op, root, ...})` — `graph`,
|
||||
`note`, `save`, `create`, `sync`, `status`, `history`, `diff`, plus
|
||||
`roots` / `rescan` / `flip` for the switcher. `root` names which tree the
|
||||
op is about and defaults to the active one; the page holds keys, never
|
||||
paths.
|
||||
`note`, `save`, `create`, `move`, `sync`, `status`, `history`, `diff`, plus
|
||||
`roots` / `rescan` / `flip` for the switcher and `amend` / `relinquish` for
|
||||
the step-up. `root` names which tree the op is about and defaults to the
|
||||
active one; the page holds keys, never paths.
|
||||
- Process → page: `window.__lens.reply(id, payload)` via `evaluate_script`;
|
||||
the shell's reach is `window.__lens.open(rel)` and
|
||||
`window.__lens.refresh()`.
|
||||
|
|
@ -103,11 +182,24 @@ accent. What the lens adds of its own:
|
|||
- The **graph** at the heart: drag, wheel to zoom, drag the void to pan,
|
||||
click to open, double-click the void to plant. Hover lights a node's
|
||||
neighbourhood and dims the rest.
|
||||
- The **switcher**: a page-drawn popover, not a native select. Nineteen trees
|
||||
in a `<select>` rendered in the compositor's own menu, outside the skin, with
|
||||
four agents labelled by eight hex characters. Now: the garden, then one card
|
||||
per agent — a colour and monogram derived from her id, her own line about
|
||||
herself, her note count and how long since she last wrote — with her cadences
|
||||
as pills beneath. The name comes from `persona.name`, then her root's
|
||||
`agent.json`, then her cadences' (`"Annie (reflection)"` names its parent).
|
||||
Six agents on this machine; four had no name until that last hop.
|
||||
- The **rail** (left): search across title, tags and description; the hint
|
||||
line counts notes, links and solos.
|
||||
line counts notes, links and solos. Folders remember what you opened, per
|
||||
tree, and a tree past forty notes starts collapsed — her memory carries
|
||||
thirty-nine folders and every one of them open is a wall. Drag a note onto a
|
||||
folder to move it; the folder counts are recursive.
|
||||
- The **drawer** (right): the note rendered, its backlinks — "linked from"
|
||||
and "links to" — and an orphan nudge that offers the freshest five notes
|
||||
to link the alone one to, one click away.
|
||||
and "links to" — **mentioned, not linked**, an orphan nudge that offers the
|
||||
freshest five notes to link the alone one to, and a `n in · n out · n loose`
|
||||
indicator in the head. **Move** renames or refolders the note and rewrites
|
||||
the links that named it.
|
||||
- The **`[[` picker**: typed inside the editor, it mirrors the caret and
|
||||
lists matching notes; enter inserts, and a dead query becomes a
|
||||
"plant and link it" act. A link button in the drawer head does the same
|
||||
|
|
@ -158,6 +250,10 @@ built on a laptop and driven over a socket; the missing step is that it be a
|
|||
|
||||
## Future
|
||||
|
||||
- **A right-click context menu.** Move, rename, link, plant, open history —
|
||||
the verbs exist and each one currently costs a trip to the drawer head or the
|
||||
palette. The rail and the canvas are where a hand already is.
|
||||
- The shared-task lane, when TASK-68's authority shape lands: the agent
|
||||
assigns human tasks through the vault; the lens surfaces them as notes.
|
||||
- Typed links, if the grammar is worth changing — see above.
|
||||
- A LICENSE for this repo, matching the substrate's AGPL-3.0.
|
||||
|
|
|
|||
|
|
@ -21,8 +21,12 @@ any markdown directory at it — the lens asks no format of its own.
|
|||
|
||||
- drag nodes, wheel to zoom, drag the void to pan
|
||||
- click a node to open the note, double-click the void to plant a new one
|
||||
- `Notes` lists the garden; search filters it; `Sync` pushes to the remote
|
||||
(ssh/gpg credentials are the user's own, like the substrate's memfs sync)
|
||||
- `Notes` lists the garden; search filters it; drag a note onto a folder to
|
||||
move it; `Sync` pushes to the remote (ssh/gpg credentials are the user's own,
|
||||
like the substrate's memfs sync)
|
||||
- the tree button opens the switcher: the garden, then every agent's memory,
|
||||
read-only. `Amend…` opens one of those for five minutes against your own
|
||||
lockscreen credential, and every write lands in her history saying so
|
||||
|
||||
## The seam
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ url="https://gitea.wiuf.net/Fimeg/souveraine-lens"
|
|||
license=('AGPL-3.0-or-later')
|
||||
# The wry host links the system webview and GTK; the runtime needs what the
|
||||
# build links. webkit2gtk-4.1 and gtk3 pull the windowing stack they need.
|
||||
depends=('gcc-libs' 'gtk3' 'webkit2gtk-4.1')
|
||||
# pam is dlopened, not linked — declared so the step-up is not a surprise
|
||||
# refusal on a machine that happens to lack it.
|
||||
depends=('gcc-libs' 'gtk3' 'webkit2gtk-4.1' 'pam')
|
||||
options=('!strip')
|
||||
source=('souveraine-lens-binary' 'org.souveraine.lens.desktop'
|
||||
'org.souveraine.lens.svg' 'LICENSE')
|
||||
|
|
|
|||
160
src/main.rs
160
src/main.rs
|
|
@ -6,12 +6,14 @@
|
|||
//! the page is a single embedded document that talks to this process over
|
||||
//! the IPC bridge and to nothing else.
|
||||
|
||||
mod stepup;
|
||||
mod ui;
|
||||
mod vault;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::json;
|
||||
use tao::event::{Event, StartCause, WindowEvent};
|
||||
|
|
@ -184,6 +186,7 @@ fn run() -> Result<()> {
|
|||
// and says so, rather than silently landing in the garden.
|
||||
if roots.get(&key).is_some() {
|
||||
roots.active = key.clone();
|
||||
roots.grant = None;
|
||||
let _ = webview.evaluate_script(&format!(
|
||||
"window.__lens.flip({});",
|
||||
serde_json::to_string(&key).unwrap_or_else(|_| "\"garden\"".into())
|
||||
|
|
@ -269,10 +272,32 @@ fn serve_ipc(path: &std::path::Path, proxy: EventLoopProxy<LensEvent>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// The human's hand, opened for one tree, for a while. Minted only by a live
|
||||
/// PAM conversation; held in memory and nowhere else, so it dies with the
|
||||
/// window. Five minutes is the shell's own StepUpAuth default.
|
||||
struct Grant {
|
||||
root: String,
|
||||
reason: String,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
const GRANT_TTL: Duration = Duration::from_secs(300);
|
||||
|
||||
impl Grant {
|
||||
fn live(&self) -> bool {
|
||||
self.at.elapsed() < GRANT_TTL
|
||||
}
|
||||
|
||||
fn remaining(&self) -> u64 {
|
||||
GRANT_TTL.saturating_sub(self.at.elapsed()).as_secs()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every tree the lens can stand in, and which one it is standing in now.
|
||||
struct Roots {
|
||||
list: Vec<vault::Root>,
|
||||
active: String,
|
||||
grant: Option<Grant>,
|
||||
}
|
||||
|
||||
impl Roots {
|
||||
|
|
@ -284,6 +309,33 @@ impl Roots {
|
|||
Self {
|
||||
list,
|
||||
active: "garden".to_string(),
|
||||
grant: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a grant that has run out. Called before every op, so an expired
|
||||
/// grant is never the thing that lets a write through.
|
||||
fn sweep(&mut self) {
|
||||
if self.grant.as_ref().is_some_and(|g| !g.live()) {
|
||||
self.grant = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn grant_for(&self, key: &str) -> Option<&Grant> {
|
||||
self.grant.as_ref().filter(|g| g.root == key && g.live())
|
||||
}
|
||||
|
||||
/// Sync stays refused even under a grant: amending her memory is one act,
|
||||
/// pushing her archive to a remote is another, and the ceremony bought
|
||||
/// only the first. DESIGN.md carries the argument.
|
||||
fn may_write(&self, root: &vault::Root, op: &str) -> bool {
|
||||
root.mode.writable() || (op != "sync" && self.grant_for(&root.key).is_some())
|
||||
}
|
||||
|
||||
fn grant_state(&self, key: &str) -> serde_json::Value {
|
||||
match self.grant_for(key) {
|
||||
Some(g) => json!({ "reason": &g.reason, "remaining": g.remaining() }),
|
||||
None => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -321,6 +373,11 @@ impl Roots {
|
|||
if self.get(&self.active.clone()).is_none() {
|
||||
self.active = "garden".to_string();
|
||||
}
|
||||
if let Some(key) = self.grant.as_ref().map(|g| g.root.clone()) {
|
||||
if self.get(&key).is_none() {
|
||||
self.grant = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -336,16 +393,27 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
|
|||
let rel = msg.get("rel").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let root_key = msg.get("root").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
|
||||
// Ops that change a tree are refused outright on an Observe root. Hiding
|
||||
// the button in the page is presentation; this is the gate.
|
||||
let writes = matches!(op, "save" | "create" | "sync");
|
||||
roots.sweep();
|
||||
|
||||
// Ops that change a tree are refused on an Observe root unless a live
|
||||
// step-up grant names it. Hiding the button in the page is presentation;
|
||||
// this is the gate, and it is the only thing standing between an agent's
|
||||
// memory and a surface that forgot whose it was.
|
||||
let writes = matches!(op, "save" | "create" | "sync" | "move");
|
||||
if writes {
|
||||
let r = roots.resolve(root_key.as_deref());
|
||||
if !r.mode.writable() {
|
||||
let refusal = json!({
|
||||
"ok": false,
|
||||
"error": format!("{} is read-only — this is {}'s memory, not the garden", r.key, r.label),
|
||||
});
|
||||
if !roots.may_write(r, op) {
|
||||
let refusal = if op == "sync" && roots.grant_for(&r.key).is_some() {
|
||||
json!({
|
||||
"ok": false, "code": "not_permitted",
|
||||
"error": format!("amending {}\u{2019}s memory is one act; pushing her archive is another", r.label),
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"ok": false, "code": "read_only",
|
||||
"error": format!("{} is read-only — this is {}\u{2019}s memory, not the garden", r.key, r.label),
|
||||
})
|
||||
};
|
||||
let script = format!(
|
||||
"window.__lens.reply({}, {});",
|
||||
id,
|
||||
|
|
@ -356,6 +424,59 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
// The ceremony. A written reason and the human's own credential, checked
|
||||
// this instant against the shell's PAM service — never a cached authority,
|
||||
// which is exactly what polkit and sudo would have handed back.
|
||||
if op == "amend" {
|
||||
let want = root_key.clone().unwrap_or_else(|| roots.active.clone());
|
||||
let reason = msg.get("reason").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
|
||||
let secret = msg.get("secret").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let payload = if roots.get(&want).is_none() {
|
||||
json!({ "ok": false, "code": "invalid_argument", "error": format!("unknown root: {want}") })
|
||||
} else if reason.is_empty() {
|
||||
json!({ "ok": false, "code": "invalid_argument", "error": "an amendment needs a reason — she will read it" })
|
||||
} else {
|
||||
match stepup::authenticate(secret) {
|
||||
Ok(()) => {
|
||||
let label = roots.get(&want).map(|r| r.label.clone()).unwrap_or_default();
|
||||
roots.grant = Some(Grant {
|
||||
root: want.clone(),
|
||||
reason: reason.clone(),
|
||||
at: Instant::now(),
|
||||
});
|
||||
emit("amend_granted", json!({ "root": &want, "reason": &reason }));
|
||||
json!({ "ok": true, "remaining": GRANT_TTL.as_secs(), "label": label, "reason": reason })
|
||||
}
|
||||
Err(e) => {
|
||||
emit("amend_refused", json!({ "root": &want }));
|
||||
json!({ "ok": false, "code": "not_permitted", "error": format!("{e:#}") })
|
||||
}
|
||||
}
|
||||
};
|
||||
let script = format!(
|
||||
"window.__lens.reply({}, {});",
|
||||
id,
|
||||
serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into())
|
||||
);
|
||||
webview.evaluate_script(&script).context("replying to the page")?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if op == "relinquish" {
|
||||
let had = roots.grant.take().is_some();
|
||||
if had {
|
||||
emit("amend_relinquished", json!({}));
|
||||
}
|
||||
let payload = json!({ "ok": true, "released": had });
|
||||
let script = format!(
|
||||
"window.__lens.reply({}, {});",
|
||||
id,
|
||||
serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into())
|
||||
);
|
||||
webview.evaluate_script(&script).context("replying to the page")?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if op == "roots" {
|
||||
let payload = json!({ "ok": true, "roots": roots.infos(), "active": &roots.active });
|
||||
let script = format!(
|
||||
|
|
@ -384,6 +505,9 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
|
|||
let known = roots.get(&want).is_some();
|
||||
if known {
|
||||
roots.active = want.clone();
|
||||
// Leaving the tree gives the hand back. A grant that survived the
|
||||
// walk to another agent's memory would be a door left open.
|
||||
roots.grant = None;
|
||||
}
|
||||
let payload = if known {
|
||||
json!({ "ok": true, "active": &roots.active })
|
||||
|
|
@ -402,6 +526,9 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
|
|||
let root = roots.resolve(root_key.as_deref());
|
||||
let vault = &root.vault;
|
||||
let mode = root.mode;
|
||||
let grant = roots.grant_state(&root.key);
|
||||
// The reason travels into her archive with every write the grant allows.
|
||||
let amend = roots.grant_for(&root.key).map(|g| g.reason.clone());
|
||||
|
||||
let result: serde_json::Value = match op {
|
||||
"graph" => match vault.scan() {
|
||||
|
|
@ -409,6 +536,7 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
|
|||
Ok(s) => json!({
|
||||
"ok": true, "graph": g, "status": s,
|
||||
"root": &root.key, "label": &root.label, "mode": mode,
|
||||
"grant": grant,
|
||||
}),
|
||||
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
|
||||
},
|
||||
|
|
@ -424,7 +552,7 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
|
|||
"save" => {
|
||||
let rel = msg.get("rel").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let raw = msg.get("raw").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match vault.save_note(rel, raw) {
|
||||
match vault.save_note(rel, raw, amend.as_deref()) {
|
||||
Ok(()) => json!({ "ok": true }),
|
||||
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
|
||||
}
|
||||
|
|
@ -432,11 +560,21 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
|
|||
"create" => {
|
||||
let title = msg.get("title").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let folder = msg.get("folder").and_then(|v| v.as_str());
|
||||
match vault.create_note_in(title, folder) {
|
||||
match vault.create_note_in_as(title, folder, amend.as_deref()) {
|
||||
Ok(rel) => json!({ "ok": true, "rel": rel }),
|
||||
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
|
||||
}
|
||||
}
|
||||
// Moving a note is a folder change, a rename, or both — one verb,
|
||||
// because to the vault they are the same git operation.
|
||||
"move" => {
|
||||
let rel = msg.get("rel").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let to = msg.get("to").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match vault.move_note(rel, to, amend.as_deref()) {
|
||||
Ok(relinked) => json!({ "ok": true, "rel": to, "relinked": relinked.len() }),
|
||||
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
|
||||
}
|
||||
}
|
||||
"sync" => match vault.sync() {
|
||||
Ok(out) => json!({ "ok": true, "out": out }),
|
||||
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
|
||||
|
|
@ -445,6 +583,7 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
|
|||
Ok(s) => json!({
|
||||
"ok": true, "status": s,
|
||||
"root": &root.key, "label": &root.label, "mode": mode,
|
||||
"grant": grant,
|
||||
}),
|
||||
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
|
||||
},
|
||||
|
|
@ -485,6 +624,7 @@ fn handle(webview: &wry::WebView, roots: &mut Roots, body: &str) -> Result<()> {
|
|||
"note" => Some(("note_opened", rel)),
|
||||
"save" => Some(("note_saved", rel)),
|
||||
"create" => Some(("note_created", rel)),
|
||||
"move" => Some(("note_moved", rel)),
|
||||
"sync" => Some(("synced", String::new())),
|
||||
_ => None,
|
||||
};
|
||||
|
|
|
|||
207
src/stepup.rs
Normal file
207
src/stepup.rs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
//! Step-up — the human's own credential, asked at the moment of the reach.
|
||||
//!
|
||||
//! `souveraine-stepup` is the shell's PAM service and accepts exactly what the
|
||||
//! lockscreen accepts. polkit and sudo both answered "authorized" on this
|
||||
//! laptop with no prompt at all, which is why neither holds this gate;
|
||||
//! DESIGN.md carries the argument. libpam is opened at runtime rather than
|
||||
//! linked, so a machine without it still reads every tree and refuses only
|
||||
//! the amend.
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::ffi::{c_char, c_int, c_void, CStr, CString};
|
||||
use std::ptr;
|
||||
|
||||
/// The service file is root-owned system config shipped by the souveraine
|
||||
/// package. Absent, `pam_start` fails and the amend is refused — never a
|
||||
/// fall-through to something weaker.
|
||||
const SERVICE: &str = "souveraine-stepup";
|
||||
|
||||
const PAM_SUCCESS: c_int = 0;
|
||||
const PAM_PROMPT_ECHO_OFF: c_int = 1;
|
||||
const PAM_PROMPT_ECHO_ON: c_int = 2;
|
||||
const PAM_CONV_ERR: c_int = 19;
|
||||
const RTLD_NOW: c_int = 2;
|
||||
|
||||
#[repr(C)]
|
||||
struct PamMessage {
|
||||
msg_style: c_int,
|
||||
msg: *const c_char,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct PamResponse {
|
||||
resp: *mut c_char,
|
||||
resp_retcode: c_int,
|
||||
}
|
||||
|
||||
type ConvFn = unsafe extern "C" fn(
|
||||
c_int,
|
||||
*const *const PamMessage,
|
||||
*mut *mut PamResponse,
|
||||
*mut c_void,
|
||||
) -> c_int;
|
||||
|
||||
#[repr(C)]
|
||||
struct PamConv {
|
||||
conv: ConvFn,
|
||||
appdata: *mut c_void,
|
||||
}
|
||||
|
||||
type StartFn =
|
||||
unsafe extern "C" fn(*const c_char, *const c_char, *const PamConv, *mut *mut c_void) -> c_int;
|
||||
type StepFn = unsafe extern "C" fn(*mut c_void, c_int) -> c_int;
|
||||
type StrerrorFn = unsafe extern "C" fn(*mut c_void, c_int) -> *const c_char;
|
||||
|
||||
extern "C" {
|
||||
fn dlopen(file: *const c_char, flags: c_int) -> *mut c_void;
|
||||
fn dlsym(handle: *mut c_void, sym: *const c_char) -> *mut c_void;
|
||||
fn calloc(n: usize, size: usize) -> *mut c_void;
|
||||
fn strdup(s: *const c_char) -> *mut c_char;
|
||||
fn getuid() -> u32;
|
||||
}
|
||||
|
||||
struct Pam {
|
||||
start: StartFn,
|
||||
authenticate: StepFn,
|
||||
acct_mgmt: StepFn,
|
||||
end: StepFn,
|
||||
strerror: StrerrorFn,
|
||||
}
|
||||
|
||||
impl Pam {
|
||||
fn open() -> Result<Self> {
|
||||
unsafe {
|
||||
let handle = dlopen(c"libpam.so.0".as_ptr(), RTLD_NOW);
|
||||
if handle.is_null() {
|
||||
bail!("libpam.so.0 is not on this machine");
|
||||
}
|
||||
let sym = |name: &CStr| -> Result<*mut c_void> {
|
||||
let p = dlsym(handle, name.as_ptr());
|
||||
if p.is_null() {
|
||||
return Err(anyhow!("libpam has no {}", name.to_string_lossy()));
|
||||
}
|
||||
Ok(p)
|
||||
};
|
||||
Ok(Pam {
|
||||
start: std::mem::transmute::<*mut c_void, StartFn>(sym(c"pam_start")?),
|
||||
authenticate: std::mem::transmute::<*mut c_void, StepFn>(sym(c"pam_authenticate")?),
|
||||
acct_mgmt: std::mem::transmute::<*mut c_void, StepFn>(sym(c"pam_acct_mgmt")?),
|
||||
end: std::mem::transmute::<*mut c_void, StepFn>(sym(c"pam_end")?),
|
||||
strerror: std::mem::transmute::<*mut c_void, StrerrorFn>(sym(c"pam_strerror")?),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer PAM's prompts from one stashed secret. A panic must never cross the
|
||||
/// FFI boundary, so nothing in here can panic: no indexing, no unwrap.
|
||||
unsafe extern "C" fn converse(
|
||||
n: c_int,
|
||||
msgs: *const *const PamMessage,
|
||||
out: *mut *mut PamResponse,
|
||||
appdata: *mut c_void,
|
||||
) -> c_int {
|
||||
if n <= 0 || msgs.is_null() || out.is_null() || appdata.is_null() {
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
let secret = &*(appdata as *const CString);
|
||||
let array = calloc(n as usize, std::mem::size_of::<PamResponse>()) as *mut PamResponse;
|
||||
if array.is_null() {
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
for i in 0..n as isize {
|
||||
let msg = *msgs.offset(i);
|
||||
let mut reply: *mut c_char = ptr::null_mut();
|
||||
if !msg.is_null() && matches!((*msg).msg_style, PAM_PROMPT_ECHO_OFF | PAM_PROMPT_ECHO_ON) {
|
||||
let text = if (*msg).msg.is_null() {
|
||||
String::new()
|
||||
} else {
|
||||
CStr::from_ptr((*msg).msg).to_string_lossy().to_lowercase()
|
||||
};
|
||||
// The fingerprint factor asks first, and an empty answer is how the
|
||||
// shell declines it — PAM then falls through to the password stack,
|
||||
// which is the credential this gate is actually asking for.
|
||||
reply = if text.contains("fingerprint") {
|
||||
strdup(c"".as_ptr())
|
||||
} else {
|
||||
strdup(secret.as_ptr())
|
||||
};
|
||||
}
|
||||
(*array.offset(i)).resp = reply;
|
||||
(*array.offset(i)).resp_retcode = 0;
|
||||
}
|
||||
*out = array;
|
||||
PAM_SUCCESS
|
||||
}
|
||||
|
||||
/// Prove the human is at the keyboard. Ok means the credential was accepted
|
||||
/// this instant — never a cached grant, never a boolean from anywhere else.
|
||||
pub fn authenticate(secret: &str) -> Result<()> {
|
||||
let secret_c = CString::new(secret).context("a passphrase cannot carry a nul byte")?;
|
||||
let user = current_user().context("this process has no local account name")?;
|
||||
let pam = Pam::open()?;
|
||||
|
||||
let service = CString::new(SERVICE)?;
|
||||
let user_c = CString::new(user)?;
|
||||
let conv = PamConv {
|
||||
conv: converse,
|
||||
appdata: &secret_c as *const CString as *mut c_void,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let mut handle: *mut c_void = ptr::null_mut();
|
||||
let rc = (pam.start)(service.as_ptr(), user_c.as_ptr(), &conv, &mut handle);
|
||||
if rc != PAM_SUCCESS || handle.is_null() {
|
||||
bail!("pam_start refused — is /etc/pam.d/{SERVICE} installed?");
|
||||
}
|
||||
let rc = (pam.authenticate)(handle, 0);
|
||||
let rc = if rc == PAM_SUCCESS {
|
||||
(pam.acct_mgmt)(handle, 0)
|
||||
} else {
|
||||
rc
|
||||
};
|
||||
let reason = CStr::from_ptr((pam.strerror)(handle, rc))
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
(pam.end)(handle, rc);
|
||||
if rc != PAM_SUCCESS {
|
||||
bail!("{reason}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The account this process is running as, read from the kernel's answer
|
||||
/// rather than an environment variable a caller could set.
|
||||
fn current_user() -> Result<String> {
|
||||
let uid = unsafe { getuid() };
|
||||
let passwd = std::fs::read_to_string("/etc/passwd").unwrap_or_default();
|
||||
for line in passwd.lines() {
|
||||
let mut f = line.split(':');
|
||||
let (Some(name), Some(_), Some(id)) = (f.next(), f.next(), f.next()) else {
|
||||
continue;
|
||||
};
|
||||
if id.parse::<u32>() == Ok(uid) {
|
||||
return Ok(name.to_string());
|
||||
}
|
||||
}
|
||||
std::env::var("USER").with_context(|| format!("uid {uid} is in no local passwd entry"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_nul_in_the_passphrase_is_refused_before_pam() {
|
||||
let err = authenticate("before\0after").unwrap_err();
|
||||
assert!(format!("{err:#}").contains("nul byte"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_process_knows_its_own_account() {
|
||||
// Every CI runner has a passwd entry for the uid it runs as; a machine
|
||||
// where it does not is exactly where the fallback matters.
|
||||
assert!(current_user().is_ok());
|
||||
}
|
||||
}
|
||||
398
src/vault.rs
398
src/vault.rs
|
|
@ -6,7 +6,7 @@
|
|||
//! to git. No database, no format of its own: the vault is files, and git is
|
||||
//! the archive.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -374,8 +374,11 @@ rel.rsplit('/').next().unwrap_or(&rel).to_string()
|
|||
})
|
||||
}
|
||||
|
||||
/// Save raw editor content back to the vault.
|
||||
pub fn save_note(&self, rel: &str, raw: &str) -> Result<()> {
|
||||
/// Save raw editor content back to the vault, and say in the archive who
|
||||
/// reached in. `amend` carries the human's written reason when this tree
|
||||
/// is not his to tend — her history is the only place she can find out,
|
||||
/// so it is never a silent write.
|
||||
pub fn save_note(&self, rel: &str, raw: &str, amend: Option<&str>) -> Result<()> {
|
||||
let path = self.root.join(rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
|
@ -395,14 +398,12 @@ rel.rsplit('/').next().unwrap_or(&rel).to_string()
|
|||
} else {
|
||||
doc.title
|
||||
};
|
||||
self.commit(
|
||||
&[rel],
|
||||
&if is_new {
|
||||
format!("note (new): {title}")
|
||||
} else {
|
||||
format!("note: {title}")
|
||||
},
|
||||
)?;
|
||||
let message = match amend {
|
||||
Some(reason) => amend_message(&title, reason),
|
||||
None if is_new => format!("note (new): {title}"),
|
||||
None => format!("note: {title}"),
|
||||
};
|
||||
self.commit(&[rel], &message)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -416,6 +417,15 @@ rel.rsplit('/').next().unwrap_or(&rel).to_string()
|
|||
/// that could climb out of the vault is refused rather than silently
|
||||
/// re-rooted — a garden keeps its roots under it.
|
||||
pub fn create_note_in(&self, title: &str, folder: Option<&str>) -> Result<String> {
|
||||
self.create_note_in_as(title, folder, None)
|
||||
}
|
||||
|
||||
pub fn create_note_in_as(
|
||||
&self,
|
||||
title: &str,
|
||||
folder: Option<&str>,
|
||||
amend: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let stem = slug_for_filename(title);
|
||||
let stem = if stem.is_empty() { "note".to_string() } else { stem };
|
||||
let rel = match folder.map(|f| f.trim().trim_matches('/')).filter(|f| !f.is_empty()) {
|
||||
|
|
@ -448,10 +458,122 @@ rel.rsplit('/').next().unwrap_or(&rel).to_string()
|
|||
} else {
|
||||
subject
|
||||
};
|
||||
self.commit(&[rel.as_str()], &format!("note: {subject}"))?;
|
||||
let message = match amend {
|
||||
Some(reason) => amend_message(&subject, reason),
|
||||
None => format!("note: {subject}"),
|
||||
};
|
||||
self.commit(&[rel.as_str()], &message)?;
|
||||
Ok(rel)
|
||||
}
|
||||
|
||||
/// Move a note, and keep the links that point at it. A folder change alone
|
||||
/// breaks nothing — links resolve by title or stem — but a stem change
|
||||
/// orphans every `[[old-stem]]` in the tree, so those are rewritten in the
|
||||
/// same commit rather than left dead.
|
||||
pub fn move_note(&self, from: &str, to: &str, amend: Option<&str>) -> Result<Vec<String>> {
|
||||
let to = to.trim().trim_matches('/');
|
||||
if to.is_empty() || !to.ends_with(".md") {
|
||||
bail!("a note is a .md file, and {to:?} is not one");
|
||||
}
|
||||
if let Some((dir, _)) = to.rsplit_once('/') {
|
||||
if !folder_is_safe(dir) {
|
||||
bail!("folder \"{dir}\" is not a safe path");
|
||||
}
|
||||
}
|
||||
if to == from {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let src = self.root.join(from);
|
||||
let dst = self.root.join(to);
|
||||
if !src.exists() {
|
||||
bail!("{from} is not in this tree");
|
||||
}
|
||||
if dst.exists() {
|
||||
bail!("{to} already exists");
|
||||
}
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tracked = self.root.join(".git").exists()
|
||||
&& self.git(&["ls-files", "--error-unmatch", "--", from]).is_ok();
|
||||
if tracked {
|
||||
self.git(&["mv", "--", from, to])?;
|
||||
} else {
|
||||
std::fs::rename(&src, &dst).with_context(|| format!("moving {from} to {to}"))?;
|
||||
}
|
||||
|
||||
let old_stem = stem_of(from);
|
||||
let new_stem = stem_of(to);
|
||||
let relinked = if old_stem == new_stem {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.retarget_links(&old_stem, &new_stem)?
|
||||
};
|
||||
let message = match amend {
|
||||
Some(reason) => amend_message(&format!("moved {from} → {to}"), reason),
|
||||
None => format!("move: {from} → {to}"),
|
||||
};
|
||||
let mut paths = vec![from.to_string(), to.to_string()];
|
||||
paths.extend(relinked.iter().cloned());
|
||||
let refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||
self.commit(&refs, &message)?;
|
||||
Ok(relinked)
|
||||
}
|
||||
|
||||
/// Rewrite `[[old]]` to `[[new]]` wherever the target names the old stem,
|
||||
/// aliases and `#anchors` intact. Returns the notes that changed.
|
||||
fn retarget_links(&self, old_stem: &str, new_stem: &str) -> Result<Vec<String>> {
|
||||
let mut docs: Vec<(String, Doc, u64)> = Vec::new();
|
||||
self.walk(&self.root, &mut docs)?;
|
||||
let mut changed = Vec::new();
|
||||
for (rel, _, _) in &docs {
|
||||
let path = self.root.join(rel);
|
||||
let Ok(raw) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let out = rewrite_wikilinks(&raw, old_stem, new_stem);
|
||||
if out != raw {
|
||||
std::fs::write(&path, &out).with_context(|| format!("relinking {rel}"))?;
|
||||
changed.push(rel.clone());
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// How many notes, without parsing one. The switcher asks this of every
|
||||
/// tree at once, so it counts files rather than walking documents.
|
||||
pub fn note_count_fast(&self) -> usize {
|
||||
fn count(dir: &Path) -> usize {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return 0;
|
||||
};
|
||||
let mut n = 0;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
n += count(&path);
|
||||
} else if path.extension().is_some_and(|e| e == "md") {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
count(&self.root)
|
||||
}
|
||||
|
||||
/// When this tree last took a commit — the switcher's "how alive is she".
|
||||
pub fn last_commit_at(&self) -> u64 {
|
||||
self.git(&["log", "-1", "--format=%ct"])
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn commit(&self, paths: &[&str], message: &str) -> Result<()> {
|
||||
if !self.root.join(".git").exists() {
|
||||
return Ok(());
|
||||
|
|
@ -610,6 +732,57 @@ rel.rsplit('/').next().unwrap_or(&rel).to_string()
|
|||
}
|
||||
}
|
||||
|
||||
/// The archive says who reached in. She reads her own history, and a human
|
||||
/// edit wearing the same subject as her own writing is the failure this avoids.
|
||||
fn amend_message(subject: &str, reason: &str) -> String {
|
||||
let head: String = subject.chars().take(40).collect();
|
||||
let reason = reason.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
format!("amend (human): {}\n\nReached in through the lens. Reason: {reason}\n", head.trim_end())
|
||||
}
|
||||
|
||||
fn stem_of(rel: &str) -> String {
|
||||
rel.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(rel)
|
||||
.trim_end_matches(".md")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Replace `[[old]]` targets with `new`, keeping `|alias` and `#anchor`. A link
|
||||
/// that named the note's title rather than its file still resolves, so it is
|
||||
/// left alone.
|
||||
fn rewrite_wikilinks(text: &str, old_stem: &str, new_stem: &str) -> String {
|
||||
let want = old_stem.to_lowercase();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(open) = rest.find("[[") {
|
||||
let Some(close) = rest[open + 2..].find("]]") else {
|
||||
break;
|
||||
};
|
||||
let inner = &rest[open + 2..open + 2 + close];
|
||||
out.push_str(&rest[..open + 2]);
|
||||
let (target, tail) = match inner.split_once('|') {
|
||||
Some((t, alias)) => (t, format!("|{alias}")),
|
||||
None => (inner, String::new()),
|
||||
};
|
||||
let (note, anchor) = match target.split_once('#') {
|
||||
Some((n, a)) => (n, format!("#{a}")),
|
||||
None => (target, String::new()),
|
||||
};
|
||||
let leaf = note.trim().rsplit('/').next().unwrap_or("").to_lowercase();
|
||||
if leaf == want {
|
||||
out.push_str(new_stem);
|
||||
out.push_str(&anchor);
|
||||
out.push_str(&tail);
|
||||
} else {
|
||||
out.push_str(inner);
|
||||
}
|
||||
rest = &rest[open + 2 + close..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// A folder path is safe when no component is empty, `.` or `..` and none
|
||||
/// carries a backslash — nothing that could step outside the vault.
|
||||
fn folder_is_safe(f: &str) -> bool {
|
||||
|
|
@ -803,10 +976,46 @@ mod tests {
|
|||
assert_eq!(found.len(), 1, "system/ and schedules/ are not agents");
|
||||
assert_eq!(found[0].key, "agent:abc123");
|
||||
assert_eq!(found[0].label, "Vanguard");
|
||||
assert_eq!(found[0].about, "the forward edge");
|
||||
assert_eq!(found[0].mode, Mode::Observe, "an agent tree is never tended");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escaped_dash_reaches_the_face_as_a_dash() {
|
||||
// Exactly what Souveraine's config.yaml holds: a backslash, a u, and
|
||||
// four hex digits — never the character itself.
|
||||
let on_disk = "\"Sovereign agent \\u2014 persistent\"";
|
||||
assert!(on_disk.contains("\\u2014"), "the fixture must carry the escape, not the dash");
|
||||
assert_eq!(unquote(on_disk), "Sovereign agent \u{2014} persistent");
|
||||
assert_eq!(unquote("'plain'"), "plain");
|
||||
assert_eq!(unquote(r#""a \qb""#), "a qb");
|
||||
assert_eq!(unquote(r#""bad \uZZZZ""#), "bad \\uZZZZ", "a broken escape stays visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nameless_agent_is_named_by_her_cadences() {
|
||||
let dir = std::env::temp_dir().join(format!("lens-cadname-{}", std::process::id()));
|
||||
let root = dir.join(".souveraine").join("agents").join("xyz789");
|
||||
std::fs::create_dir_all(root.join("memory").join(".git")).unwrap();
|
||||
let reflection = root.join("cadences").join("reflection");
|
||||
std::fs::create_dir_all(reflection.join("memory").join(".git")).unwrap();
|
||||
std::fs::write(
|
||||
reflection.join("agent.json"),
|
||||
r#"{"name": "Annie (reflection)", "description": "The reflection cadence of Annie"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let found = discover_agents(&dir);
|
||||
// No config.yaml and no agent.json at her root — the cadence knows.
|
||||
let primary = found.iter().find(|r| r.key == "agent:xyz789").unwrap();
|
||||
assert_eq!(primary.group, "Annie", "a uuid fragment is not a name");
|
||||
assert_eq!(primary.label, "Annie");
|
||||
let cadence = found.iter().find(|r| r.key == "agent:xyz789/reflection").unwrap();
|
||||
assert_eq!(cadence.group, "Annie", "her cadences group under her");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_memory_without_git_is_not_a_root() {
|
||||
let dir = std::env::temp_dir().join(format!("lens-nogit-{}", std::process::id()));
|
||||
|
|
@ -820,6 +1029,60 @@ mod tests {
|
|||
assert!(Mode::Tend.writable());
|
||||
assert!(!Mode::Observe.writable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_keeps_the_links_pointing_at_it() {
|
||||
let dir = std::env::temp_dir().join(format!("lens-move-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("index.md"), "# Home\n\nSee [[old-name]] and [[old-name#Anchor|the alias]].\n").unwrap();
|
||||
std::fs::write(dir.join("old-name.md"), "# Old Name\n").unwrap();
|
||||
let vault = Vault::new(dir.clone());
|
||||
|
||||
// A folder change alone leaves every link alone.
|
||||
vault.move_note("old-name.md", "notes/old-name.md", None).unwrap();
|
||||
let home = std::fs::read_to_string(dir.join("index.md")).unwrap();
|
||||
assert!(home.contains("[[old-name]]"));
|
||||
|
||||
// A stem change rewrites them, alias and anchor intact.
|
||||
let relinked = vault.move_note("notes/old-name.md", "notes/new-name.md", None).unwrap();
|
||||
assert_eq!(relinked, vec!["index.md".to_string()], "only the notes that changed");
|
||||
let home = std::fs::read_to_string(dir.join("index.md")).unwrap();
|
||||
assert!(home.contains("[[new-name]]"), "got {home}");
|
||||
assert!(home.contains("[[new-name#Anchor|the alias]]"), "got {home}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_refuses_what_would_leave_the_vault() {
|
||||
let dir = std::env::temp_dir().join(format!("lens-move-x-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.md"), "# A\n").unwrap();
|
||||
std::fs::write(dir.join("b.md"), "# B\n").unwrap();
|
||||
let vault = Vault::new(dir.clone());
|
||||
assert!(vault.move_note("a.md", "../a.md", None).is_err());
|
||||
assert!(vault.move_note("a.md", "x/../../a.md", None).is_err());
|
||||
assert!(vault.move_note("a.md", "a", None).is_err(), "a note is a .md file");
|
||||
assert!(vault.move_note("a.md", "b.md", None).is_err(), "never over an existing note");
|
||||
assert!(vault.move_note("ghost.md", "c.md", None).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_amend_says_so_in_the_archive() {
|
||||
let m = amend_message("her note", "fixed the date I got wrong");
|
||||
assert!(m.starts_with("amend (human): her note"));
|
||||
assert!(m.lines().next().unwrap().len() < 60);
|
||||
assert!(m.contains("Reason: fixed the date I got wrong"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_named_by_title_survives_a_stem_rename() {
|
||||
let text = "[[Old Name]] and [[old-name]] and [[sub/old-name|alias]]";
|
||||
let out = rewrite_wikilinks(text, "old-name", "new-name");
|
||||
assert!(out.contains("[[Old Name]]"), "the title still resolves: {out}");
|
||||
assert!(out.contains("[[new-name]]"), "{out}");
|
||||
assert!(out.contains("[[new-name|alias]]"), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Roots ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -850,6 +1113,14 @@ pub struct Root {
|
|||
pub key: String,
|
||||
pub label: String,
|
||||
pub mode: Mode,
|
||||
/// The being this tree belongs to. Her cadences carry their own labels;
|
||||
/// the group is what holds them together as one agent in the switcher.
|
||||
pub group: String,
|
||||
/// The agent's own line about herself, from `persona.description`.
|
||||
pub about: String,
|
||||
/// The agent id the tree belongs to — one identity across her cadences,
|
||||
/// which is what lets the switcher colour them as one being.
|
||||
pub agent: String,
|
||||
pub vault: Vault,
|
||||
}
|
||||
|
||||
|
|
@ -863,6 +1134,11 @@ pub struct RootInfo {
|
|||
pub group: String,
|
||||
/// Which cadence inside that group — "primary", "reflection", "archivist".
|
||||
pub cadence: String,
|
||||
pub about: String,
|
||||
pub agent: String,
|
||||
pub notes: usize,
|
||||
/// Unix seconds of the tree's last commit; 0 when git has nothing to say.
|
||||
pub updated: u64,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
|
|
@ -871,24 +1147,31 @@ impl Root {
|
|||
key: "garden".into(),
|
||||
label: "Garden".into(),
|
||||
mode: Mode::Tend,
|
||||
group: "garden".into(),
|
||||
about: "yours to tend".into(),
|
||||
agent: String::new(),
|
||||
vault,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(&self) -> RootInfo {
|
||||
let (group, cadence) = match self.key.strip_prefix("agent:") {
|
||||
None => ("garden".to_string(), "vault".to_string()),
|
||||
let cadence = match self.key.strip_prefix("agent:") {
|
||||
None => "vault".to_string(),
|
||||
Some(rest) => match rest.split_once('/') {
|
||||
Some((_, cad)) => (self.label.clone(), cad.to_string()),
|
||||
None => (self.label.clone(), "primary".to_string()),
|
||||
Some((_, cad)) => cad.to_string(),
|
||||
None => "primary".to_string(),
|
||||
},
|
||||
};
|
||||
RootInfo {
|
||||
key: self.key.clone(),
|
||||
label: self.label.clone(),
|
||||
mode: self.mode,
|
||||
group,
|
||||
group: self.group.clone(),
|
||||
cadence,
|
||||
about: self.about.clone(),
|
||||
agent: self.agent.clone(),
|
||||
notes: self.vault.note_count_fast(),
|
||||
updated: self.vault.last_commit_at(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -920,6 +1203,31 @@ fn persona_from_config(path: &Path) -> (Option<String>, Option<String>) {
|
|||
(name, desc)
|
||||
}
|
||||
|
||||
/// Who she is, when her own root says nothing. Four of the six agents on this
|
||||
/// machine carry no `config.yaml` and no `agent.json` at all — but each of
|
||||
/// their cadences does, and it names the parent: `"Annie (reflection)"`. The
|
||||
/// suffix comes off and the name is hers. Filesystem only; the lens never asks
|
||||
/// the server who anyone is.
|
||||
fn name_from_cadences(dir: &Path) -> Option<String> {
|
||||
let mut dirs: Vec<PathBuf> = std::fs::read_dir(dir.join("cadences"))
|
||||
.ok()?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
for cadence in dirs {
|
||||
let Some(name) = name_from_agent_json(&cadence.join("agent.json")) else {
|
||||
continue;
|
||||
};
|
||||
let base = name.split(" (").next().unwrap_or(&name).trim();
|
||||
if !base.is_empty() {
|
||||
return Some(base.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `agent.json` carries a display name for the cadence trees.
|
||||
fn name_from_agent_json(path: &Path) -> Option<String> {
|
||||
let raw = std::fs::read_to_string(path).ok()?;
|
||||
|
|
@ -934,11 +1242,47 @@ fn name_from_agent_json(path: &Path) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
/// Strip the quotes and decode what a double-quoted YAML scalar may carry.
|
||||
/// Souveraine's own `config.yaml` holds `—` as six literal characters,
|
||||
/// and a description that reads "Sovereign agent — persistent" in the
|
||||
/// switcher is the escape leaking into the face.
|
||||
fn unquote(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix('"').and_then(|r| r.strip_suffix('"')).unwrap_or(s);
|
||||
let s = s.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')).unwrap_or(s);
|
||||
s.to_string()
|
||||
if let Some(inner) = s.strip_prefix('"').and_then(|r| r.strip_suffix('"')) {
|
||||
return unescape(inner);
|
||||
}
|
||||
s.strip_prefix('\'')
|
||||
.and_then(|r| r.strip_suffix('\''))
|
||||
.unwrap_or(s)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn unescape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('u') => {
|
||||
let hex: String = chars.by_ref().take(4).collect();
|
||||
match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
|
||||
Some(decoded) => out.push(decoded),
|
||||
None => {
|
||||
out.push_str("\\u");
|
||||
out.push_str(&hex);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some('n') => out.push('\n'),
|
||||
Some('t') => out.push('\t'),
|
||||
Some(other) => out.push(other),
|
||||
None => out.push('\\'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Directories under `agents/` that are not agents.
|
||||
|
|
@ -969,8 +1313,12 @@ pub fn discover_agents(home: &Path) -> Vec<Root> {
|
|||
continue;
|
||||
}
|
||||
|
||||
let (name, _desc) = persona_from_config(&dir.join("config.yaml"));
|
||||
let display = name.unwrap_or_else(|| short_id(id));
|
||||
let (name, desc) = persona_from_config(&dir.join("config.yaml"));
|
||||
let display = name
|
||||
.or_else(|| name_from_agent_json(&dir.join("agent.json")))
|
||||
.or_else(|| name_from_cadences(&dir))
|
||||
.unwrap_or_else(|| short_id(id));
|
||||
let about = desc.unwrap_or_default();
|
||||
|
||||
let primary = dir.join("memory");
|
||||
if primary.join(".git").is_dir() {
|
||||
|
|
@ -978,6 +1326,9 @@ pub fn discover_agents(home: &Path) -> Vec<Root> {
|
|||
key: format!("agent:{id}"),
|
||||
label: display.clone(),
|
||||
mode: Mode::Observe,
|
||||
group: display.clone(),
|
||||
about: about.clone(),
|
||||
agent: id.to_string(),
|
||||
vault: Vault::new(primary),
|
||||
});
|
||||
}
|
||||
|
|
@ -1001,6 +1352,9 @@ pub fn discover_agents(home: &Path) -> Vec<Root> {
|
|||
key: format!("agent:{id}/{cname}"),
|
||||
label,
|
||||
mode: Mode::Observe,
|
||||
group: display.clone(),
|
||||
about: about.clone(),
|
||||
agent: id.to_string(),
|
||||
vault: Vault::new(mem),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue