feat: khipu standalone — notas P2P soberanas content-addressed, local-first (front-door, git-dep al monorepo)

Front-door limpio: solo crates del dominio; Llimphi y lo fundacional por
git-dep del monorepo gioser.git. cargo check pasa (5 crates, 0 errores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:18:02 +00:00
commit bf782ebef1
36 changed files with 13630 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
*.pdb
+78
View File
@@ -0,0 +1,78 @@
# khipu
> `khipu` (quechua: nudos de cuerdas para registrar memoria). Notas con gravedad temporal.
Captura de notas rápidas donde el olvido es parte del modelo: cada nota tiene una masa que decae con el tiempo y se refuerza con cada acceso. Lo recurrente queda visible; lo que no se vuelve a tocar se va difuminando hasta caer del horizonte.
## Instalación
```sh
cargo run --release -p khipu-app
```
## Compatibilidad
- **Linux / macOS / Windows** — UI Llimphi (Wayland/X11/Win32 vía `winit`).
- Persistencia local en `$XDG_DATA_HOME/khipu/`.
## Crates
| Crate | Rol |
|---|---|
| [`khipu-core`](khipu-core/README.md) | Modelo de nota + store; sin UI. |
| [`khipu-gravity`](khipu-gravity/README.md) | Algoritmo de masa/decay; refuerzo por acceso. |
| `khipu-share` | Sobres de notas firmados (Ed25519) y direccionados por contenido (BLAKE3) sobre agora; transporte TCP/LAN + descubrimiento UDP + identidad cifrada. |
| `khipu-brahman` | Transporte de sobres sobre libp2p (BrahmanNet): stream cifrado + descubrimiento por DHT. |
| [`khipu-app`](khipu-app/README.md) | UI Llimphi sobre el core. |
## Gravedad semántica (embeddings)
El canvas de la derecha agrupa las notas por afinidad. Los vectores salen del `verbo-daemon` si está corriendo; si no, de un hash-trigram local.
```sh
# Embeddings reales (clústeres y vecinos semánticos de verdad):
cargo run -p rimay-verbo-daemon-bin -- --provider fastembed # escucha en $XDG_RUNTIME_DIR/verbo.sock
cargo run --release -p khipu-app # lo detecta solo al arrancar
```
Sin daemon, khipu cae al embebedor trigram de 16d — determinista y offline, idéntico al comportamiento histórico. El cálculo nunca bloquea la UI: viaja a un worker y reentra al bucle cuando termina. Si el espacio vectorial cambia entre dos arranques (arrancó/cayó el daemon, otro modelo), los vectores se recalculan automáticamente.
## Compartir (agora)
`exportar` sella en `compartido.khipu` un sobre firmado Ed25519 con la identidad del cuaderno y direccionado por su hash BLAKE3 de contenido.
La identidad vive **cifrada** (Argon2id + ChaCha20-Poly1305, vía `agora-keystore`) en `<datos>/keys/` — la semilla privada nunca queda en claro en disco. Al primer intento de compartir, khipu pide una passphrase: la primera vez la crea, después la usa para descifrar. `KHIPU_PASSPHRASE` en el entorno desbloquea sin prompt (headless). Una `identidad.seed` en claro de versiones viejas se migra al keystore (y se borra el claro) automáticamente. Comparte **lo que el buscador esté filtrando** (vacío = todo el cuaderno), así que escribir en la búsqueda y exportar manda sólo ese subconjunto. `importar` verifica firma + hash y, si cuadra, ingiere las notas, marcándolas con una etiqueta de procedencia `de:<autor>` (visible en el editor como «✎ de: …»).
Lo que viaja es el **contenido** (título, cuerpo, etiquetas), nunca la física temporal: al importar, cada nota nace fresca (masa plena, acceso = ahora) — su gravedad arranca en el cuaderno que la recibe. Los wiki-links `[[Título]]` se rearman solos porque khipu resuelve enlaces por título. Reimportar el mismo sobre no duplica (se omiten títulos ya presentes). Un sobre alterado o con firma ajena se rechaza entero, sin autoridad central.
Para compartir en vivo sin copiar archivos: `publicar` levanta un servidor TCP que sirve el cuaderno (puerto `KHIPU_BIND`, default `127.0.0.1:7700`) **y anuncia una baliza UDP** para que lo descubran en la LAN. `recibir` abre un panel con un **campo de dirección** (`host:puerto`, prellenado y editable) y, debajo, los **pares descubiertos en la LAN** (nombre · autor · dirección): click en uno para jalarle el cuaderno, o escribí una dirección y «jalar». El transporte es `std::net` puro y **no necesita ser confiable** — el receptor verifica firma + hash antes de ingerir; la baliza sólo dice *dónde*, no *qué*.
**WAN / libp2p**: el campo de dirección de `recibir` acepta dos formas, autodetectadas: `host:puerto` (TCP directo) o una **multiaddr libp2p** —directa `/ip4/…/p2p/<id>` o de circuito `/ip4/…/p2p/<relay>/p2p-circuit/p2p/<id>`. Al `publicar`, khipu sirve por libp2p (stream cifrado Noise sobre `BrahmanNet`, protocolo `/khipu/sobre/1.0.0`, vía `khipu-brahman`) y muestra tu dirección de marcado.
**NAT traversal**: `BrahmanNet` ahora trae **Circuit Relay v2 + DCUtR** (`card-net`). Un nodo alcanzable hace de relay; uno detrás de NAT reserva un circuito ahí y queda accesible vía la dirección de circuito. Configurá `KHIPU_RELAY=/ip4/…/tcp/…/p2p/<relay-id>` antes de `publicar` y khipu reserva el circuito y muestra la dirección para compartir. Las direcciones externas no se confían a ciegas: **AutoNAT** las confirma pidiendo dial-backs a otros peers, y sólo las confirmadas se anuncian (y entran en las reservas de relay). En la malla Brahman AutoNAT corre con `only_global_ips=false` para confirmar también en LAN/loopback.
**Descubrimiento por DHT**: con `KHIPU_BOOTSTRAP=/ip4/…/p2p/<id>` (un nodo de la malla), khipu se une a la DHT Kademlia al arrancar; `publicar` se anuncia bajo la clave khipu y `recibir` lista —además de los pares LAN— los pares hallados por DHT (filas `DHT · …<id>`), que se jalan por peer-id. Así dos khipu se encuentran sin compartir IP ni multiaddr a mano, sólo conociendo un bootstrap común. Verificado end-to-end en localhost (rendezvous + publicador + receptor; 4 tests en `khipu-brahman`).
La lógica vive en `khipu-share`: `net` (transporte TCP) y `discovery` (baliza UDP). 15 tests + un test de integración que recorre la cadena completa descubrir→jalar→verificar en loopback.
## Estado (2026-05-31)
### Hecho
- `khipu-core` (modelo de nota + store) + `khipu-gravity` (masa/decay con refuerzo por acceso).
- `khipu-app`: UI Llimphi sobre el core, con menú principal y contextual.
- Gravedad semántica: clustering por embeddings del `verbo-daemon` (rimay), con fallback trigram 16d offline; cálculo en worker que no bloquea la UI.
- Compartir vía agora (`khipu-share`): sobres firmados Ed25519 + direccionados BLAKE3, identidad cifrada (Argon2id + ChaCha20-Poly1305 en keystore), compartir selectivo + procedencia del autor; transporte TCP/LAN + descubrimiento por baliza UDP (15 tests + integración loopback).
- WAN/P2P (`khipu-brahman` sobre libp2p/BrahmanNet): stream cifrado Noise, NAT traversal (Circuit Relay v2 + DCUtR), AutoNAT, descubrimiento por DHT Kademlia (4 tests e2e localhost).
### Pendiente
- Sincronización bidireccional / resolución de conflictos entre cuadernos (hoy es import unidireccional de sobres).
- Transferir física temporal opcional al compartir (hoy el contenido nace fresco en el receptor — decisión de diseño, no bug).
- Endurecimiento de la malla DHT en WAN real (probado en localhost/LAN).
## Consideraciones
- **No es un sistema de "todo"** — no hay due-dates ni recordatorios; es un cuaderno con física propia.
- El decay es transparente: cada nota expone su masa actual; el usuario decide si la salva.
- La gravedad es local y no transferible: compartir mueve el contenido, no la atención.
+30
View File
@@ -0,0 +1,30 @@
# khipu
> `khipu` (Quechua: knotted cord recordkeeping). Notes with temporal gravity.
Quick-note capture where forgetting is part of the model: each note has a mass that decays with time and reinforces with each access. What's recurrent stays visible; what isn't touched fades until it falls off the horizon.
## Install
```sh
cargo run --release -p khipu-app
```
## Compatibility
- **Linux / macOS / Windows** — Llimphi UI (Wayland/X11/Win32 via `winit`).
- Local persistence in `$XDG_DATA_HOME/khipu/`.
## Crates
| Crate | Role |
|---|---|
| [`khipu-core`](khipu-core/README.md) | Note model + store; no UI. |
| [`khipu-gravity`](khipu-gravity/README.md) | Mass/decay algorithm; reinforcement on access. |
| [`khipu-app`](khipu-app/README.md) | Llimphi UI over the core. |
## Considerations
- **It's not a "todo" system** — no due dates, no reminders; it's a notebook with its own physics.
- Decay is transparent: each note shows its current mass; the user decides whether to save it.
- Plays well with the [agora](../../03_ukupacha/agora/README.md) network: notes can be shared without losing their local gravity.
+32
View File
@@ -0,0 +1,32 @@
<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
# khipu
> `khipu` (runa-simi: watasqa wask'akuna yuyanapaq). Qillqakuna pacha tinkuywan.
Usqhay nota hap'iy. Qunqaytaqa modelopa ukhunpi: sapanka nota llasayuq, pachawan miran, sapa kuti rikuptin kallpachayuq. Sapa kuti rikuq nota qhipanqa; mana takyaqkuna chinkanqaku, ñawi hawamanta wikch'urunqakama.
## Churay
```sh
cargo run --release -p khipu-app
```
## Tinkuy
- **Linux / macOS / Windows** — Llimphi UI (Wayland/X11/Win32 `winit` patapi).
- Allpa-yana waqaychay `$XDG_DATA_HOME/khipu/`-pi.
## Crateskuna
| Crate | Ima ruwan |
|---|---|
| [`khipu-core`](khipu-core/README.md) | Nota modelo + waqaychay; mana UI. |
| [`khipu-gravity`](khipu-gravity/README.md) | Llasay/chinkay algoritmo, rikuq kallpachay. |
| [`khipu-app`](khipu-app/README.md) | Llimphi UI core patapi. |
## Yuyaykunaq
- **Manan "todo" sistemachu** — mana fechakuna, mana willaqkuna; pi-físikayuq qillqana kanmi.
- Chinkayqa rikukullan: sapanka nota llasayninta rikuchin; runa salvanaqa rikun.
- [agora](../../03_ukupacha/agora/README.md) ayllu-redwan tinkuy atin: nota willanakuyqa mana lokal llasayninmanta hark'aqasqa.
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "khipu-app"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "khipu-app — cuaderno de notas sobre Llimphi: lista editable in-situ + canvas de gravedad semántica con persistencia en $XDG_DATA_HOME/khipu/."
[[bin]]
name = "khipu-app"
path = "src/main.rs"
[[example]]
name = "demo_cli"
path = "examples/demo_cli.rs"
[dependencies]
khipu-core = { path = "../khipu-core" }
khipu-gravity = { path = "../khipu-gravity" }
khipu-share = { path = "../khipu-share" }
khipu-brahman = { path = "../khipu-brahman" }
agora-core = { workspace = true }
rimay-verbo = { workspace = true }
tokio = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-list = { workspace = true }
llimphi-widget-text-editor = { workspace = true }
llimphi-widget-text-input = { workspace = true }
llimphi-widget-tiled = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-edit-menu = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-motion = { workspace = true }
llimphi-clipboard = { workspace = true }
app-bus = { workspace = true }
directories = { workspace = true }
postcard = { workspace = true }
serde = { workspace = true }
+17
View File
@@ -0,0 +1,17 @@
# khipu-app
> UI Llimphi sobre el core de [khipu](../README.md). Binario del usuario.
App de escritorio: lista de notas ordenadas por masa actual, editor inline (Markdown ligero), captura rápida (`Ctrl+N`), búsqueda fuzzy. Cada redibujo recalcula masa con [`khipu-gravity`](../khipu-gravity/README.md) y muestra notas con `mass > umbral`; las que cayeron se acceden por menú "archivo".
## Uso
```sh
cargo run --release -p khipu-app
```
## Deps
- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`
- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/) para preferencias compartidas
+17
View File
@@ -0,0 +1,17 @@
# khipu-app
> Llimphi UI over the core of [khipu](../README.md). The user binary.
Desktop app: list of notes sorted by current mass, inline editor (lightweight Markdown), quick capture (`Ctrl+N`), fuzzy search. Each redraw recomputes mass via [`khipu-gravity`](../khipu-gravity/README.md) and shows notes with `mass > threshold`; fallen ones are accessed via the "archive" menu.
## Usage
```sh
cargo run --release -p khipu-app
```
## Deps
- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`
- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/) for shared prefs
@@ -0,0 +1,159 @@
//! `demo_cli` — demostración no gráfica del cuaderno.
//!
//! Siembra un cuaderno personal, imprime el grafo de wiki-links
//! (forward-links, backlinks, huérfanas, enlaces colgantes) y luego la
//! gravedad semántica: los clústeres por afinidad y los vecinos más
//! cercanos de una nota.
//!
//! Los vectores semánticos van a mano —tres tópicos: cocina, jardín,
//! oficina— para que el clustering se vea con claridad. En la app real
//! los produce `verbo`. Corre con
//! `cargo run -p khipu-app --example demo_cli --release`.
use khipu_core::{NoteId, NoteStore};
use khipu_gravity::{Gravity, GravityConfig, Params, SemanticField};
/// Vector de tópico con un leve sesgo — notas del mismo tema quedan
/// afines sin ser idénticas.
fn topic(base: [f32; 3], nudge: f32) -> Vec<f32> {
vec![base[0] + nudge, base[1] + nudge * 0.3, base[2] - nudge * 0.2]
}
fn main() {
let cocina = [1.0, 0.0, 0.0];
let jardin = [0.0, 1.0, 0.0];
let oficina = [0.0, 0.0, 1.0];
let mut store = NoteStore::new();
let mut field = SemanticField::new();
let seed: [(&str, &str, &[&str], Vec<f32>); 7] = [
(
"Índice",
"mi cuaderno: [[Recetas de la abuela]], [[Jardín]] y [[Oficina]]",
&["meta"],
topic(cocina, 0.0),
),
(
"Recetas de la abuela",
"sopa de auyama; ver también [[Lista del mercado]]",
&["cocina"],
topic(cocina, 0.05),
),
(
"Lista del mercado",
"auyama, cilantro, pan; vuelve al [[Índice]]",
&["cocina"],
topic(cocina, 0.10),
),
(
"Jardín",
"riego semanal; las [[Semillas de cilantro]] van en marzo",
&["jardín"],
topic(jardin, 0.05),
),
(
"Semillas de cilantro",
"germinan en diez días",
&["jardín"],
topic(jardin, 0.10),
),
(
"Oficina",
"[[Reunión del lunes]] y pendientes varios",
&["trabajo"],
topic(oficina, 0.05),
),
(
"Diario sin enlaces",
"una nota suelta, no la enlaza nadie y enlaza a [[Algo Perdido]]",
&["personal"],
topic(oficina, 0.50),
),
];
let mut ids: Vec<(NoteId, String)> = Vec::new();
for (title, body, tags, vector) in seed {
let tags = tags.iter().map(|t| t.to_string()).collect();
let id = store.create(title, body, tags, 1_700_000_000);
field.insert(id, vector);
ids.push((id, title.to_string()));
}
let name = |id: NoteId| {
ids.iter()
.find(|(i, _)| *i == id)
.map(|(_, n)| n.as_str())
.unwrap_or("?")
};
println!("\n khipu · cuaderno de notas — {} notas\n", store.len());
println!(" grafo de enlaces:");
for note in store.iter() {
let fwd: Vec<&str> = store.forward_links(note.id).into_iter().map(name).collect();
let back: Vec<&str> = store.backlinks(note.id).into_iter().map(name).collect();
println!(" «{}»", note.title);
println!(" enlaza a : {}", fmt_list(&fwd));
println!(" backlinks : {}", fmt_list(&back));
}
let orphans: Vec<&str> = store.orphans().iter().map(|n| n.title.as_str()).collect();
println!("\n notas huérfanas (sin backlinks): {}", fmt_list(&orphans));
let dangling_owned = store.dangling_links();
let dangling: Vec<&str> = dangling_owned.iter().map(|s| s.as_str()).collect();
println!(" enlaces colgantes (destino inexistente): {}", fmt_list(&dangling));
println!("\n gravedad semántica — clústeres (afinidad ≥ 0.85):");
for (n, cluster) in field.clusters(0.85).iter().enumerate() {
let titles: Vec<&str> = cluster.iter().map(|id| name(*id)).collect();
println!(" grupo {}: {}", n + 1, fmt_list(&titles));
}
let pivot = ids[1].0; // "Recetas de la abuela"
println!("\n vecinos más afines a «{}»:", name(pivot));
for (id, score) in field.nearest(pivot, 3) {
println!(" {:.3} {}", score, name(id));
}
let layout = field.gravity_layout(&GravityConfig::default());
println!("\n layout 2D por gravedad ({} posiciones):", layout.len());
for p in &layout {
println!(" ({:7.1}, {:7.1}) {}", p.x, p.y, name(p.id));
}
println!("\n masa temporal — vida media 7 días, boost 0.4, horizonte 0.10:");
let g = Gravity::new(Params::default());
let pivot_b = ids[4].0; // "Semillas de cilantro"
println!(" «{}» — escenario:", name(pivot_b));
let mut mass = 1.0_f32;
println!(" inicial mass = {:.3}", mass);
mass = g.decay(mass, 3.0 * 24.0 * 3600.0);
println!(
" 3 días sin acceso → decay mass = {:.3} ({})",
mass,
if g.is_visible(mass) { "visible" } else { "archivo" }
);
mass = g.decay(mass, 14.0 * 24.0 * 3600.0);
println!(
" +14 días sin acceso → decay mass = {:.3} ({})",
mass,
if g.is_visible(mass) { "visible" } else { "archivo" }
);
mass = g.reinforce(mass);
println!(
" el usuario abre la nota → reinforce mass = {:.3} ({})",
mass,
if g.is_visible(mass) { "visible" } else { "archivo" }
);
println!();
}
/// Formatea una lista de nombres, o `—` si está vacía.
fn fmt_list(items: &[&str]) -> String {
if items.is_empty() {
"".to_string()
} else {
items.join(", ")
}
}
File diff suppressed because it is too large Load Diff
+655
View File
@@ -0,0 +1,655 @@
//! `map` — el lienzo de pensamientos: geometría de cámara, colocación
//! determinista de notas (anclaje), detección de regiones emergentes y el
//! pintado del mapa (nodos que respiran por masa, filamentos, topónimos).
//!
//! El resto de la app le pide vistas y consultas; los helpers compartidos
//! (`current_mass`, `now_secs`, `button`, `CLUSTER_THRESHOLD`) viven en la
//! raíz y se alcanzan por `crate::`.
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
AlignItems, Dimension, JustifyContent,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle as KurboCircle, Stroke};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::llimphi_text::{draw_block, Alignment, TextBlock};
use llimphi_ui::{DragPhase, View};
use llimphi_widget_text_input::{text_input_view, TextInputPalette};
use khipu_core::NoteId;
use crate::panels::button;
use crate::{current_mass, now_secs, Focus, Model, Msg, CLUSTER_THRESHOLD};
/// Un nodo del mapa, ya resuelto a coordenadas de mundo + su masa viva.
/// Datos planos para viajar dentro de la closure de pintura.
pub(crate) struct MapNode {
id: NoteId,
/// Coordenadas de mundo (el domicilio fijo de la nota).
x: f32,
y: f32,
/// Masa "vivida" en el instante del render: enciende el brillo y el
/// tamaño. Decae con el tiempo → el mapa respira sin que toques nada.
mass: f32,
/// `false` si cayó bajo el horizonte (sólo se ve con archivo activo).
visible: bool,
color: Color,
label: String,
}
/// Mundo → pantalla local (relativa al rect del lienzo). El centro del
/// rect es el ancla del zoom; `pan` se suma en mundo, luego se escala.
pub(crate) fn world_to_local(wx: f32, wy: f32, w: f32, h: f32, pan: (f32, f32), zoom: f32) -> (f32, f32) {
(w * 0.5 + (wx + pan.0) * zoom, h * 0.5 + (wy + pan.1) * zoom)
}
/// Inversa de [`world_to_local`]: pantalla local → mundo. Para resolver
/// qué nota cae bajo un click.
pub(crate) fn local_to_world(lx: f32, ly: f32, w: f32, h: f32, pan: (f32, f32), zoom: f32) -> (f32, f32) {
let z = zoom.max(1e-3);
((lx - w * 0.5) / z - pan.0, (ly - h * 0.5) / z - pan.1)
}
/// La nota colocada más cercana a un click en coords locales, dentro de un
/// radio de tolerancia (~18 px de pantalla). `None` si el click cae en el
/// vacío — así arrastrar el fondo no cambia la selección.
pub(crate) fn pick_note(model: &Model, lx: f32, ly: f32, w: f32, h: f32) -> Option<NoteId> {
let (wx, wy) = local_to_world(lx, ly, w, h, model.cam_pan, model.cam_zoom);
let now = now_secs();
let mut best: Option<(NoteId, f32)> = None;
for id in &model.order {
let Some(n) = model.store.get(*id) else { continue };
let Some((px, py)) = n.pos else { continue };
if !model.show_archive {
let m = current_mass(&model.gravity, n, now);
if !model.gravity.is_visible(m) {
continue;
}
}
let d2 = (px - wx).powi(2) + (py - wy).powi(2);
if best.map(|(_, bd)| d2 < bd).unwrap_or(true) {
best = Some((*id, d2));
}
}
let tol = (18.0 / model.cam_zoom.max(1e-3)).powi(2);
best.filter(|(_, d2)| *d2 <= tol).map(|(id, _)| id)
}
/// Separación mínima entre nodos al colocarlos (coordenadas de mundo).
pub(crate) const MAP_MIN_SEP: f32 = 30.0;
/// Ángulo áureo en radianes — reparte determinísticamente lo que no tiene
/// parentela semántica sin amontonarlo.
pub(crate) const GOLDEN_ANGLE: f32 = 2.399_963_2;
/// Le da a `id` un domicilio fijo en el mapa, **una sola vez**: cae en el
/// baricentro de sus parientes semánticos (ponderado por afinidad) y, si
/// quedó pegada a otra nota, se separa apenas. Determinista y dependiente
/// sólo de las notas ya asentadas, así el orden de inserción es estable y
/// el mapa nunca se reacomoda solo.
pub(crate) fn place_note(model: &mut Model, id: NoteId) {
if model.store.get(id).map(|n| n.pos.is_some()).unwrap_or(true) {
return; // ya tiene domicilio (o no existe): no se mueve.
}
// Vecinos ya colocados: su afinidad con la nota nueva y su posición.
let mut kin: Vec<(f32, (f32, f32))> = Vec::new();
for other in &model.order {
if *other == id {
continue;
}
let Some(pos) = model.store.get(*other).and_then(|n| n.pos) else { continue };
let aff = model.field.affinity(id, *other).unwrap_or(0.0).max(0.0);
kin.push((aff, pos));
}
let target = if kin.is_empty() {
(0.0, 0.0) // primera nota del cuaderno: centro del mundo.
} else {
let wsum: f32 = kin.iter().map(|(w, _)| *w).sum();
if wsum > 1e-3 {
// Cae junto a su parentela: baricentro ponderado por afinidad.
let (mut tx, mut ty) = (0.0_f32, 0.0_f32);
for (w, (x, y)) in &kin {
tx += w * x;
ty += w * y;
}
(tx / wsum, ty / wsum)
} else {
// Ortogonal a todo: anillo determinista por id, lejos del núcleo.
let ang = id as f32 * GOLDEN_ANGLE;
let rad = 180.0 + 14.0 * (id as f32).sqrt();
(rad * ang.cos(), rad * ang.sin())
}
};
// Separación: empuja el target hasta despegarlo de cada vecino cercano.
let mut p = target;
for _ in 0..12 {
let mut moved = false;
for (_, q) in &kin {
let dx = p.0 - q.0;
let dy = p.1 - q.1;
let d = (dx * dx + dy * dy).sqrt();
if d < MAP_MIN_SEP {
let (ux, uy) = if d > 1e-3 {
(dx / d, dy / d)
} else {
let a = id as f32 * GOLDEN_ANGLE;
(a.cos(), a.sin())
};
let push = MAP_MIN_SEP - d;
p.0 += ux * push;
p.1 += uy * push;
moved = true;
}
}
if !moved {
break;
}
}
model.store.set_pos(id, p.0, p.1);
}
/// Envuelve `child` como cajón absoluto pegado al borde izquierdo, alto
/// completo con un margen. El mapa de fondo sigue recibiendo pan/zoom en
/// el resto de la ventana; sólo los clicks sobre el cajón los come él.
pub(crate) fn overlay_left(child: View<Msg>, width: f32) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(8.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
right: auto(),
},
size: Size {
width: length(width),
height: auto(),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.children(vec![child])
}
/// Columna interna del editor: barra de cierre (× ⇒ deselecciona) arriba +
/// el editor abajo, sobre `bg_panel`. La comparten el overlay lateral y la
/// tarjeta anclada del zoom semántico.
pub(crate) fn editor_shell(child: View<Msg>, theme: &Theme) -> View<Msg> {
let close = button("× cerrar", theme.bg_button, theme.fg_muted, Msg::Deselect);
let close_row = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
flex_shrink: 0.0,
justify_content: Some(JustifyContent::End),
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel_alt)
.children(vec![close]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![close_row, child])
}
/// Envuelve `child` como panel absoluto pegado al borde derecho, alto
/// completo, con barra de cierre. El editor del nodo abierto cuando se lo
/// edita de lejos (zoom bajo): un fallback práctico al anclaje in-situ.
pub(crate) fn overlay_right(child: View<Msg>, width: f32, theme: &Theme) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: auto(),
top: length(8.0_f32),
bottom: length(8.0_f32),
right: length(8.0_f32),
},
size: Size {
width: length(width),
height: auto(),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.children(vec![editor_shell(child, theme)])
}
/// Mundo → pantalla local usando el último tamaño de lienzo conocido + la
/// cámara. La versión de `view()`, donde el rect real aún no se sabe.
pub(crate) fn world_screen(model: &Model, wx: f32, wy: f32) -> (f32, f32) {
let (w, h) = model.canvas_size;
world_to_local(wx, wy, w, h, model.cam_pan, model.cam_zoom)
}
/// Posición de pantalla (local al lienzo) del nodo `id`. `None` si la nota
/// no tiene domicilio todavía.
pub(crate) fn node_screen_pos(model: &Model, id: NoteId) -> Option<(f32, f32)> {
let (wx, wy) = model.store.get(id).and_then(|n| n.pos)?;
Some(world_screen(model, wx, wy))
}
/// Mínimo de notas para que un clúster cuente como región candidata.
pub(crate) const REGION_MIN_MEMBERS: usize = 3;
/// Distancia de mundo dentro de la cual una región ya "posee" un clúster:
/// si hay un topónimo así de cerca del centroide, no se vuelve a ofrecer.
pub(crate) const REGION_MATCH_DIST: f32 = 140.0;
/// Centroides (mundo) de los clústeres densos que todavía no tienen una
/// región cerca — los lugares que el mapa ofrece bautizar. Sólo cuentan
/// miembros colocados y visibles.
pub(crate) fn unnamed_cluster_centroids(model: &Model) -> Vec<(f32, f32)> {
let now = now_secs();
let mut out = Vec::new();
for cluster in model.field.clusters(CLUSTER_THRESHOLD) {
let pts: Vec<(f32, f32)> = cluster
.iter()
.filter_map(|id| {
let n = model.store.get(*id)?;
let p = n.pos?;
let m = current_mass(&model.gravity, n, now);
(model.show_archive || model.gravity.is_visible(m)).then_some(p)
})
.collect();
if pts.len() < REGION_MIN_MEMBERS {
continue;
}
let (sx, sy) = pts.iter().fold((0.0, 0.0), |(ax, ay), (x, y)| (ax + x, ay + y));
let c = (sx / pts.len() as f32, sy / pts.len() as f32);
let d2 = REGION_MATCH_DIST * REGION_MATCH_DIST;
let near_named = model
.regions
.iter()
.any(|r| (r.x - c.0).powi(2) + (r.y - c.1).powi(2) <= d2);
let naming_here = model
.naming
.map(|(nx, ny)| (nx - c.0).powi(2) + (ny - c.1).powi(2) <= d2)
.unwrap_or(false);
if !near_named && !naming_here {
out.push(c);
}
}
out
}
/// Chip clickeable "✛ nombrar zona" que ofrece bautizar el clúster denso
/// en `(wx, wy)`. Al click abre el input de bautizo en esa coordenada.
pub(crate) fn name_region_chip(wx: f32, wy: f32, theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(theme.bg_button)
.radius(12.0)
.hover_fill(theme.bg_button_hover)
.text_aligned("✛ nombrar zona", 11.0, theme.fg_muted, Alignment::Center)
.on_click(Msg::BeginNaming(wx, wy))
}
/// Mini-input del bautizo en curso: una tarjeta con el campo de texto
/// enfocado. Enter confirma, Esc cancela (en `on_key`).
pub(crate) fn naming_input(model: &Model, input_palette: &TextInputPalette) -> View<Msg> {
let input = text_input_view(
&model.region_input,
"nombre de la zona…",
model.focus == Focus::Region,
input_palette,
Msg::Focus(Focus::Region),
);
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
..Default::default()
})
.fill(model.theme.bg_panel)
.radius(8.0)
.children(vec![input])
}
/// Posiciona `child` como vista absoluta de tamaño `(w, h)` centrada en la
/// pantalla `(sx, sy)`, clampeada al lienzo. Para chips y mini-inputs que
/// viven en el mapa (sugerencia de bautizo, input de nombre).
pub(crate) fn pinned(child: View<Msg>, sx: f32, sy: f32, w: f32, h: f32, canvas: (f32, f32)) -> View<Msg> {
let left = (sx - w * 0.5).clamp(4.0, (canvas.0 - w - 4.0).max(4.0));
let top = (sy - h * 0.5).clamp(4.0, (canvas.1 - h - 4.0).max(4.0));
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(left),
top: length(top),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(w),
height: length(h),
},
..Default::default()
})
.children(vec![child])
}
/// La tarjeta del nodo abierto, anclada a su coordenada `(nx, ny)` de
/// pantalla: el zoom semántico hecho carne — el editor vive EN el lugar del
/// pensamiento, no en un panel aparte. Se clampea para no salirse del
/// lienzo. Hija del canvas, así pan/zoom la arrastran con el nodo.
pub(crate) fn node_card(child: View<Msg>, nx: f32, ny: f32, canvas: (f32, f32), theme: &Theme) -> View<Msg> {
let (cw_max, ch_max) = canvas;
let cw = 380.0_f32.min((cw_max - 16.0).max(220.0));
let ch = 440.0_f32.min((ch_max - 16.0).max(200.0));
// Anclada bajo el nodo, centrada en X, clampeada a la ventana.
let left = (nx - cw * 0.5).clamp(8.0, (cw_max - cw - 8.0).max(8.0));
let top = (ny + 16.0).clamp(8.0, (ch_max - ch - 8.0).max(8.0));
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(left),
top: length(top),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(cw),
height: length(ch),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.radius(8.0)
.children(vec![editor_shell(child, theme)])
}
pub(crate) fn gravity_panel(model: &Model, injected: Vec<View<Msg>>) -> View<Msg> {
let theme = model.theme;
let now = now_secs();
let clusters = model.field.clusters(CLUSTER_THRESHOLD);
let selected = model.selected;
let pan = model.cam_pan;
let zoom = model.cam_zoom;
// Nodos colocados (los que ya tienen domicilio), con su masa viva.
let mut nodes: Vec<MapNode> = Vec::new();
for id in &model.order {
let Some(n) = model.store.get(*id) else { continue };
let Some((x, y)) = n.pos else { continue };
let mass = current_mass(&model.gravity, n, now);
let visible = model.gravity.is_visible(mass);
if !visible && !model.show_archive {
continue;
}
nodes.push(MapNode {
id: *id,
x,
y,
mass,
visible,
color: cluster_color(*id, &clusters, theme),
label: short_label(&n.title),
});
}
// Topónimos: las regiones bautizadas, para pintarlas como rótulos de
// continente detrás de los nodos.
let regions: Vec<(String, f32, f32)> = model
.regions
.iter()
.map(|r| (r.name.clone(), r.x, r.y))
.collect();
// Filamentos del nodo seleccionado: sus parientes más afines ya
// colocados. Elegir un pensamiento enciende sus vecinos (activación
// por difusión) — el motor de serendipia.
let mut links: Vec<((f32, f32), (f32, f32), f32)> = Vec::new();
if let Some(sel) = selected {
if let Some(sp) = model.store.get(sel).and_then(|n| n.pos) {
for (nid, aff) in model.field.nearest(sel, 6) {
if aff < 0.20 {
continue;
}
if let Some(np) = model.store.get(nid).and_then(|n| n.pos) {
links.push((sp, np, aff));
}
}
}
}
let canvas = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel_alt)
.paint_with(move |scene, ts, rect| {
paint_map(scene, ts, rect, &nodes, &links, &regions, selected, pan, zoom, theme);
})
.draggable_at(|phase, dx, dy, _lx, _ly| match phase {
DragPhase::Move => Some(Msg::MapPan(dx, dy)),
DragPhase::End => None,
})
.on_scroll(|_dx, dy| Some(Msg::MapZoom(dy)))
.on_click_at(|lx, ly, w, h| Some(Msg::MapClick(lx, ly, w, h)))
// La tarjeta del nodo abierto (zoom semántico) viaja como hija del
// canvas: se pinta encima de los nodos y la cámara la arrastra con el
// pensamiento al que pertenece.
.children(injected);
View::new(Style {
size: Size {
width: Dimension::auto(),
height: percent(1.0_f32),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(4.0_f32),
right: length(4.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![canvas])
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_map(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
ts: &mut llimphi_ui::llimphi_text::Typesetter,
rect: llimphi_ui::PaintRect,
nodes: &[MapNode],
links: &[((f32, f32), (f32, f32), f32)],
regions: &[(String, f32, f32)],
selected: Option<NoteId>,
pan: (f32, f32),
zoom: f32,
theme: Theme,
) {
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
// Pantalla absoluta = origen del rect + pantalla local.
let to_screen = |wx: f32, wy: f32| -> (f64, f64) {
let (lx, ly) = world_to_local(wx, wy, rect.w, rect.h, pan, zoom);
((rect.x + lx) as f64, (rect.y + ly) as f64)
};
// Topónimos al fondo: el nombre de cada región, grande y tenue, como
// rótulo de continente; un halo suave insinúa su territorio.
for (name, rx, ry) in regions {
let (cx, cy) = to_screen(*rx, *ry);
let blob = KurboCircle::new((cx, cy), (96.0 * zoom as f64).max(34.0));
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
with_alpha(theme.accent, 0.05),
None,
&blob,
);
let size = (15.0 * zoom).clamp(11.0, 28.0);
// Centrado aproximado: `simple` alinea a la izquierda en (x, y).
let est_w = name.chars().count() as f64 * size as f64 * 0.52;
draw_block(
scene,
ts,
&TextBlock::simple(
name,
size,
with_alpha(theme.fg_text, 0.30),
(cx - est_w * 0.5, cy - size as f64 * 0.6),
),
);
}
// Filamentos primero (debajo de los nodos). Más opacos cuanto más afín.
for (a, b, aff) in links {
let (ax, ay) = to_screen(a.0, a.1);
let (bx, by) = to_screen(b.0, b.1);
let mut path = BezPath::new();
path.move_to((ax, ay));
path.line_to((bx, by));
let alpha = (0.18 + aff * 0.55).clamp(0.0, 0.85);
scene.stroke(
&Stroke::new((0.8 + *aff as f64 * 1.6).max(0.6)),
Affine::IDENTITY,
with_alpha(theme.accent, alpha),
None,
&path,
);
}
// Nodos: tamaño y brillo crecen con la masa viva (el mapa respira).
for n in nodes {
let (px, py) = to_screen(n.x, n.y);
let m = n.mass.clamp(0.0, 2.0);
// Radio base por masa, escalado apenas por zoom para no inflarse.
let r = (3.0 + m * 4.5) * (0.6 + 0.4 * zoom.clamp(0.5, 1.5));
// Brillo: las notas frescas arden; las que se enfrían se apagan
// hacia el fondo. Bajo el horizonte (archivo) van casi transparentes.
let glow = if n.visible {
(0.35 + m * 0.45).clamp(0.0, 1.0)
} else {
0.18
};
let color = with_alpha(n.color, glow);
// Halo tenue alrededor de las notas más encendidas.
if n.visible && m > 0.6 {
let halo = KurboCircle::new((px, py), (r + 5.0) as f64);
scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(n.color, 0.10), None, &halo);
}
let circle = KurboCircle::new((px, py), r as f64);
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &circle);
if selected == Some(n.id) {
let ring = KurboCircle::new((px, py), (r + 3.0) as f64);
scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, theme.accent, None, &ring);
}
// Etiqueta: sólo si el zoom da espacio o es la seleccionada — para
// no saturar el mapa lejano. El texto sale del Typesetter.
if (zoom >= 0.9 || selected == Some(n.id)) && n.visible {
let lbl_col = with_alpha(theme.fg_text, (glow + 0.25).clamp(0.0, 1.0));
draw_block(
scene,
ts,
&TextBlock::simple(&n.label, 10.0, lbl_col, (px + r as f64 + 4.0, py - 7.0)),
);
}
}
}
pub(crate) fn cluster_color(id: NoteId, clusters: &[Vec<NoteId>], theme: Theme) -> Color {
let idx = clusters.iter().position(|c| c.contains(&id)).unwrap_or(0);
// Paleta tomada del theme + matices generados por golden-ratio
// sobre el hue del accent. Determinista por índice.
let palette: [Color; 6] = [
theme.accent,
with_alpha(rotate_hue(theme.accent, 0.16), 1.0),
with_alpha(rotate_hue(theme.accent, 0.33), 1.0),
with_alpha(rotate_hue(theme.accent, 0.50), 1.0),
with_alpha(rotate_hue(theme.accent, 0.66), 1.0),
with_alpha(rotate_hue(theme.accent, 0.83), 1.0),
];
palette[idx % palette.len()]
}
pub(crate) fn with_alpha(c: Color, alpha: f32) -> Color {
let [r, g, b, _] = c.components;
Color::new([r, g, b, alpha])
}
pub(crate) fn rotate_hue(c: Color, dh: f32) -> Color {
// RGB → HSV → rota H → RGB. Aproximación, alpha fijo.
let [r, g, b, a] = c.components;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let v = max;
let s = if max <= 0.0 { 0.0 } else { (max - min) / max };
let h = if (max - min).abs() < 1e-6 {
0.0
} else if max == r {
((g - b) / (max - min)) % 6.0
} else if max == g {
(b - r) / (max - min) + 2.0
} else {
(r - g) / (max - min) + 4.0
};
let h2 = ((h / 6.0) + dh).rem_euclid(1.0) * 6.0;
let c2 = v * s;
let x = c2 * (1.0 - ((h2 % 2.0) - 1.0).abs());
let (r2, g2, b2) = match h2 as i32 {
0 => (c2, x, 0.0),
1 => (x, c2, 0.0),
2 => (0.0, c2, x),
3 => (0.0, x, c2),
4 => (x, 0.0, c2),
_ => (c2, 0.0, x),
};
let m = v - c2;
Color::new([r2 + m, g2 + m, b2 + m, a])
}
pub(crate) fn short_label(s: &str) -> String {
let mut out: String = s.chars().take(24).collect();
if s.chars().count() > 24 {
out.push('…');
}
out
}
+226
View File
@@ -0,0 +1,226 @@
//! `net` — el lado P2P del cuaderno: sellar/exportar el sobre firmado,
//! verificar e ingerir uno ajeno, descubrir pares y publicarse por libp2p.
//! El transporte (LAN directo + WAN vía `card-net`/Kademlia + relay/NAT)
//! vive en `khipu-share`/`khipu-brahman`; acá sólo se orquesta.
use std::sync::Arc;
use khipu_share::{SharedNote, SignedBundle};
use llimphi_ui::Handle;
use crate::panels::note_matches;
use crate::{khipu_dir, now_secs, schedule_embedding, Model, Msg, P2p};
pub(crate) fn export_notebook(model: &Model) -> String {
let Some(kp) = model.keypair.as_ref() else {
return "sin identidad para firmar".into();
};
let Some(dir) = khipu_dir() else {
return "sin directorio de datos".into();
};
// Compartir selectivo: si hay texto en el buscador, exportamos sólo
// las notas que filtra (mismo criterio que la lista); si está vacío,
// todo el cuaderno.
let query = model.search.text();
let q = query.trim();
let notes: Vec<SharedNote> = model
.order
.iter()
.filter_map(|id| model.store.get(*id))
.filter(|n| q.is_empty() || note_matches(n, q))
.map(SharedNote::from_note)
.collect();
if notes.is_empty() {
return "no hay notas para exportar (¿el filtro no coincide?)".into();
}
let n = notes.len();
let sobre = match khipu_share::seal(kp, notes, now_secs()) {
Ok(s) => s,
Err(_) => return "falló el sellado".into(),
};
let Ok(bytes) = sobre.to_bytes() else {
return "falló serializar el sobre".into();
};
let path = dir.join("compartido.khipu");
let tmp = path.with_extension("khipu.tmp");
if std::fs::write(&tmp, &bytes)
.and_then(|_| std::fs::rename(&tmp, &path))
.is_err()
{
return "no se pudo escribir el sobre".into();
}
let hash = sobre.content_address().unwrap_or([0u8; 32]);
let filtro = if q.is_empty() {
String::new()
} else {
format!(" (filtro «{q}»)")
};
format!(
"exportadas {n} notas{filtro} → compartido.khipu · {}",
hex8(&hash)
)
}
/// Verifica e ingiere `compartido.khipu`. Las notas nuevas nacen con
/// gravedad fresca; sus embeddings se recalculan en segundo plano. Un
/// sobre con firma inválida se rechaza entero. Devuelve la línea de estado.
pub(crate) fn import_notebook(model: &mut Model, h: &Handle<Msg>) -> String {
let Some(dir) = khipu_dir() else {
return "sin directorio de datos".into();
};
let path = dir.join("compartido.khipu");
let Ok(bytes) = std::fs::read(&path) else {
return "no hay compartido.khipu para importar".into();
};
let sobre = match SignedBundle::from_bytes(&bytes) {
Ok(s) => s,
Err(_) => return "sobre ilegible".into(),
};
let outcome = match khipu_share::open(&sobre) {
Ok(bundle) => khipu_share::import_into(&mut model.store, bundle, now_secs()),
Err(_) => return "firma inválida — sobre rechazado".into(),
};
for id in &outcome.created {
model.order.push(*id);
schedule_embedding(model, *id, h);
}
format!(
"importadas {} · omitidas {} (ya existían)",
outcome.created.len(),
outcome.skipped
)
}
/// Dirección donde el servidor escucha. `KHIPU_BIND` la sobrescribe;
/// default localhost para no exponerse sin querer.
pub(crate) fn bind_addr() -> String {
std::env::var("KHIPU_BIND").unwrap_or_else(|_| "127.0.0.1:7700".into())
}
/// Dirección del par a quien jalarle el cuaderno. `KHIPU_PEER` la
/// sobrescribe; default coincide con [`bind_addr`] para probar en local.
pub(crate) fn peer_addr() -> String {
std::env::var("KHIPU_PEER").unwrap_or_else(|_| "127.0.0.1:7700".into())
}
/// Arma el nodo libp2p la primera vez que se necesita: runtime tokio
/// dedicado + `KhipuNode` que empieza a escuchar (para ser alcanzable y
/// obtener nuestra dirección de marcado). Idempotente. `false` si no se
/// pudo (sin runtime o sin red).
pub(crate) fn ensure_p2p(model: &mut Model) -> bool {
if model.p2p.is_some() {
return true;
}
let Ok(rt) = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
else {
return false;
};
// `KhipuNode::standalone` arranca el swarm con `tokio::spawn`: hay que
// estar dentro del runtime.
let node = {
let _g = rt.enter();
match khipu_brahman::KhipuNode::standalone() {
Ok(n) => Arc::new(n),
Err(_) => return false,
}
};
let dial_addr = rt
.block_on(node.listen_str("/ip4/0.0.0.0/tcp/0"))
.unwrap_or_default();
// Si hay un nodo bootstrap configurado, nos unimos a la malla DHT para
// poder descubrir y ser descubiertos (`anunciar`/`descubrir`).
if let Ok(boot) = std::env::var("KHIPU_BOOTSTRAP") {
let _ = node.dial_str(&boot);
}
model.p2p = Some(P2p {
rt: Arc::new(rt),
node,
dial_addr,
serving: false,
});
true
}
/// Levanta (una sola vez) el servidor TCP que sirve `compartido.khipu`.
/// El hilo lee el archivo en cada conexión, así sirve siempre la versión
/// vigente; vive hasta que el proceso termina. Devuelve la línea de estado.
pub(crate) fn start_publishing(model: &mut Model, h: &Handle<Msg>) -> String {
if model.publishing {
return format!("ya publicando en {}", bind_addr());
}
let Some(dir) = khipu_dir() else {
return "sin directorio de datos".into();
};
let addr = bind_addr();
let listener = match std::net::TcpListener::bind(&addr) {
Ok(l) => l,
Err(e) => return format!("no se pudo escuchar en {addr}: {e}"),
};
// Puerto efectivo (resuelve `:0` si se usara) para anunciarlo en la baliza.
let tcp_port = listener.local_addr().map(|a| a.port()).unwrap_or(0);
let path = dir.join("compartido.khipu");
std::thread::spawn(move || {
khipu_share::net::serve_loop(listener, move || std::fs::read(&path));
});
// Baliza periódica para que los pares nos descubran sin saber la IP.
let beacon = khipu_share::discovery::Beacon {
author: model.keypair.as_ref().map(|k| k.public_key()).unwrap_or([0u8; 32]),
port: tcp_port,
name: "khipu".into(),
};
std::thread::spawn(move || loop {
let _ = khipu_share::discovery::anunciar(&beacon);
std::thread::sleep(std::time::Duration::from_secs(2));
});
model.publishing = true;
// Además del TCP/LAN, servimos por libp2p (cifrado, WAN). El nodo se
// arma perezoso; servimos `compartido.khipu` y nos anunciamos en la DHT.
let p2p_status = if ensure_p2p(model) {
let dir2 = dir.clone();
if let Some(p) = model.p2p.as_mut() {
if !p.serving {
let path2 = dir2.join("compartido.khipu");
let node = p.node.clone();
let _g = p.rt.enter();
node.run_serve(move || std::fs::read(&path2).ok());
node.anunciar();
p.serving = true;
}
// Si hay un relay configurado (KHIPU_RELAY=/ip4/.../p2p/<id>),
// reservamos un circuito ahí para ser alcanzables detrás de NAT.
// Async (dial + identify + reserva tardan ~2s): cuando termina,
// reentra con Msg::RelayReady para mostrar la dirección.
if let Ok(relay) = std::env::var("KHIPU_RELAY") {
let (rt, node, h2) = (p.rt.clone(), p.node.clone(), h.clone());
rt.spawn(async move {
let _ = node.dial_str(&relay);
// Esperamos a que AutoNAT confirme la dirección del relay
// (boot_delay + dial-back) antes de pedir la reserva.
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
let circuit = format!("{relay}/p2p-circuit");
let msg = match node.listen_str(&circuit).await {
Ok(addr) => addr,
Err(e) => format!("falló reservar circuito: {e}"),
};
h2.dispatch(Msg::RelayReady(msg));
});
}
format!(" · libp2p: {}", p.dial_addr)
} else {
String::new()
}
} else {
String::new()
};
format!("publicando en {addr} (LAN){p2p_status}")
}
/// Prefijo hex (4 bytes / 8 hex) de un hash, para mostrar una dirección
/// de contenido sin abrumar.
pub(crate) fn hex8(hash: &[u8; 32]) -> String {
hash[..4].iter().map(|b| format!("{b:02x}")).collect()
}
+728
View File
@@ -0,0 +1,728 @@
//! `panels` — los constructores de vistas en flujo: cabecera, cajón de
//! notas (lista + búsqueda), panel de recibir (pares P2P), y el editor de
//! la nota (título/cuerpo/etiquetas + stats). Frontends puros sobre el
//! `Model`; la lógica vive en la raíz y en `map`.
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Rect, Size, Style},
AlignItems, Dimension, JustifyContent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
use llimphi_widget_text_editor::{text_editor_view, EditorMetrics, EditorPalette};
use llimphi_widget_text_input::{text_input_view, TextInputPalette};
use khipu_core::{Note, NoteId};
use crate::{
current_mass, now_secs, Focus, Model, Msg, EDITOR_VISIBLE_LINES, FIELD_LABEL_SIZE, HEADER_H,
LIST_WIDTH, ROW_H,
};
pub(crate) fn header_view(model: &Model) -> View<Msg> {
let title = format!("khipu · {} notas", model.store.len());
let title_node = View::new(Style {
size: Size {
width: Dimension::auto(),
height: percent(1.0_f32),
},
flex_grow: 1.0,
padding: Rect {
left: length(14.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(title, 14.0, model.theme.fg_text, Alignment::Start);
let list_label = if model.show_list { "ocultar notas" } else { "☰ notas" };
let list_btn = button(
list_label,
model.theme.bg_button,
if model.show_list { model.theme.accent } else { model.theme.fg_muted },
Msg::ToggleList,
);
let new_btn = button(
"+ nueva (Ctrl+N)",
model.theme.bg_button,
model.theme.fg_text,
Msg::NewNote,
);
let archive_label = if model.show_archive {
"ocultar archivo"
} else {
"ver archivo"
};
let archive_btn = button(
archive_label,
model.theme.bg_button,
model.theme.fg_muted,
Msg::ToggleArchive,
);
let del_btn = button(
"borrar",
model.theme.bg_button,
model.theme.fg_muted,
Msg::DeleteSelected,
);
let export_btn = button(
"exportar",
model.theme.bg_button,
model.theme.fg_muted,
Msg::Export,
);
let import_btn = button(
"importar",
model.theme.bg_button,
model.theme.fg_muted,
Msg::Import,
);
let publish_label = if model.publishing {
"publicando"
} else {
"publicar"
};
let publish_btn = button(
publish_label,
model.theme.bg_button,
if model.publishing {
model.theme.accent
} else {
model.theme.fg_muted
},
Msg::Publish,
);
let receive_btn = button(
"recibir",
model.theme.bg_button,
model.theme.fg_muted,
Msg::Receive,
);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(HEADER_H),
},
flex_shrink: 0.0,
padding: Rect {
left: length(0.0_f32),
right: length(10.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(model.theme.bg_panel_alt)
.children(vec![
title_node,
list_btn,
new_btn,
archive_btn,
del_btn,
export_btn,
import_btn,
publish_btn,
receive_btn,
])
}
pub(crate) fn button(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
// El ancho crece con el largo del texto — los labels más
// explícitos («+ nueva (Ctrl+N)», «ocultar archivo») piden más
// espacio que un «borrar» seco.
let chars = label.chars().count() as f32;
let width = (chars * 7.2 + 22.0).max(86.0);
View::new(Style {
size: Size {
width: length(width),
height: length(26.0_f32),
},
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(bg)
.radius(4.0)
.text_aligned(label.to_string(), 11.0, fg, Alignment::Center)
.on_click(msg)
}
pub(crate) fn list_panel(
model: &Model,
palette: &ListPalette,
input_palette: &TextInputPalette,
) -> View<Msg> {
let now = now_secs();
let query = model.search.text();
let q = query.trim();
// Particionamos en horizonte vs archivo y ordenamos cada parte por
// masa viva decreciente. Si hay query, ambas listas quedan
// pre-filtradas por coincidencia en título/cuerpo/etiquetas.
let mut visible: Vec<(NoteId, f32, &Note)> = Vec::new();
let mut archive: Vec<(NoteId, f32, &Note)> = Vec::new();
let mut hidden_by_query = 0usize;
for id in &model.order {
let Some(n) = model.store.get(*id) else {
continue;
};
if !q.is_empty() && !note_matches(n, q) {
hidden_by_query += 1;
continue;
}
let m = current_mass(&model.gravity, n, now);
if model.gravity.is_visible(m) {
visible.push((*id, m, n));
} else {
archive.push((*id, m, n));
}
}
let by_mass_desc = |a: &(NoteId, f32, &Note), b: &(NoteId, f32, &Note)| {
b.1.partial_cmp(&a.1)
.unwrap_or(core::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
};
visible.sort_by(by_mass_desc);
archive.sort_by(by_mass_desc);
let mut chain: Vec<(NoteId, f32, &Note)> = visible.clone();
if model.show_archive {
chain.extend(archive.iter().cloned());
}
let rows: Vec<ListRow<Msg>> = chain
.into_iter()
.map(|(id, mass, n)| ListRow {
label: row_label(n, mass),
selected: Some(id) == model.selected,
on_click: Msg::SelectNote(id),
})
.collect();
let caption = if !q.is_empty() {
format!(
"buscar «{}» · {}/{} coinciden",
q,
visible.len() + if model.show_archive { archive.len() } else { 0 },
visible.len() + archive.len() + hidden_by_query
)
} else if archive.is_empty() {
format!("notas · {}", visible.len())
} else if model.show_archive {
format!(
"notas · {} horizonte + {} archivo",
visible.len(),
archive.len()
)
} else {
format!(
"notas · {} horizonte (+{} archivo)",
visible.len(),
archive.len()
)
};
let spec = ListSpec {
total: rows.len(),
rows,
caption: Some(caption),
truncated_hint: None,
row_height: ROW_H,
palette: *palette,
};
let search_input = text_input_view(
&model.search,
"buscar (título, cuerpo, etiquetas)",
model.focus == Focus::Search,
input_palette,
Msg::Focus(Focus::Search),
);
let search_row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
flex_shrink: 0.0,
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
..Default::default()
})
.fill(model.theme.bg_panel_alt)
.children(vec![search_input]);
let list_wrap = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![list_view(spec)]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: length(LIST_WIDTH),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![search_row, list_wrap])
}
/// Panel izquierdo en modo "recibir": arriba un input de dirección manual
/// (`host:puerto`, habilita WAN) con botones jalar/cancelar; debajo, la
/// lista de pares descubiertos en la LAN (click ⇒ jalar de él). Reemplaza
/// transitoriamente la lista de notas.
pub(crate) fn receive_panel(
model: &Model,
palette: &ListPalette,
input_palette: &TextInputPalette,
) -> View<Msg> {
// Fila de dirección manual + jalar.
let addr_input = text_input_view(
&model.peer_input,
"host:puerto o /ip4/…/p2p/…",
model.focus == Focus::PeerAddr,
input_palette,
Msg::Focus(Focus::PeerAddr),
);
let addr_wrap = View::new(Style {
size: Size {
width: Dimension::auto(),
height: length(26.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.children(vec![addr_input]);
let jalar = button(
"jalar",
model.theme.bg_button,
model.theme.accent,
Msg::FetchManual,
);
let addr_row = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(34.0_f32),
},
flex_shrink: 0.0,
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(6.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(model.theme.bg_panel_alt)
.children(vec![addr_wrap, jalar]);
let cancel = button(
"cancelar",
model.theme.bg_button,
model.theme.fg_muted,
Msg::CancelPeers,
);
let cancel_row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(34.0_f32),
},
flex_shrink: 0.0,
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(2.0_f32),
bottom: length(4.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(model.theme.bg_panel_alt)
.children(vec![cancel]);
let rows: Vec<ListRow<Msg>> = model
.peers
.iter()
.map(|p| ListRow {
label: p.label.clone(),
selected: false,
on_click: Msg::FetchFrom(p.addr.clone()),
})
.collect();
let caption = if model.peers.is_empty() {
"pares en la LAN: ninguno aún".to_string()
} else {
format!("pares en la LAN · {} (click para jalar)", model.peers.len())
};
let spec = ListSpec {
total: rows.len(),
rows,
caption: Some(caption),
truncated_hint: None,
row_height: ROW_H,
palette: *palette,
};
let list_wrap = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![list_view(spec)]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: length(LIST_WIDTH),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![addr_row, cancel_row, list_wrap])
}
/// Coincidencia sobre título, cuerpo y etiquetas. Case-insensitive.
pub(crate) fn note_matches(n: &Note, query: &str) -> bool {
if n.matches(query) {
return true;
}
let q = query.to_lowercase();
n.tags.iter().any(|t| t.to_lowercase().contains(&q))
}
pub(crate) fn row_label(n: &Note, mass: f32) -> String {
let title = if n.title.is_empty() {
"(sin título)"
} else {
n.title.as_str()
};
// Una barra de tres bloques visualiza la masa (0..1.5 mapeada a
// 0..3). Sobre el horizonte se ve llena; cayendo, se vacía.
let bars = (mass.clamp(0.0, 1.5) / 0.5).round() as usize;
let glyph: String = (0..3)
.map(|i| if i < bars { '▮' } else { '▯' })
.collect();
format!("{glyph} {title}")
}
pub(crate) fn editor_panel(
model: &Model,
input_palette: &TextInputPalette,
editor_palette: &EditorPalette,
) -> View<Msg> {
let none_view = || -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(model.theme.bg_panel)
.text_aligned(
"selecciona o crea una nota".to_string(),
12.0,
model.theme.fg_muted,
Alignment::Center,
)
};
if model.selected.is_none() {
return wrap_panel(model, none_view());
}
let metrics = EditorMetrics::for_font_size(13.0);
let title_field = field(
model,
"título",
text_input_view(
&model.title,
"(sin título)",
model.focus == Focus::Title,
input_palette,
Msg::Focus(Focus::Title),
),
);
let body_input = text_editor_view(
&model.body,
editor_palette,
metrics,
EDITOR_VISIBLE_LINES,
|ev| Some(Msg::EditorPointer(ev)),
);
let body_field = body_field_view(model, body_input);
let tags_field = field(
model,
"etiquetas (coma separadas)",
text_input_view(
&model.tags,
"p. ej. cocina, jardín",
model.focus == Focus::Tags,
input_palette,
Msg::Focus(Focus::Tags),
),
);
let stats = stats_view(model);
let column = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(10.0_f32),
bottom: length(10.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
},
..Default::default()
})
.fill(model.theme.bg_panel)
.children(vec![title_field, body_field, tags_field, stats]);
wrap_panel(model, column)
}
pub(crate) fn wrap_panel(_model: &Model, child: View<Msg>) -> View<Msg> {
View::new(Style {
size: Size {
width: Dimension::auto(),
height: percent(1.0_f32),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![child])
}
pub(crate) fn field(model: &Model, label: &str, control: View<Msg>) -> View<Msg> {
let label_node = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(14.0_f32),
},
..Default::default()
})
.text_aligned(
label.to_string(),
FIELD_LABEL_SIZE,
model.theme.fg_muted,
Alignment::Start,
);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_shrink: 0.0,
gap: Size {
width: length(0.0_f32),
height: length(2.0_f32),
},
..Default::default()
})
.children(vec![label_node, control])
}
pub(crate) fn body_field_view(model: &Model, editor: View<Msg>) -> View<Msg> {
let label_node = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(14.0_f32),
},
..Default::default()
})
.text_aligned(
"cuerpo (wiki-links con [[Título]])".to_string(),
FIELD_LABEL_SIZE,
model.theme.fg_muted,
Alignment::Start,
);
let focused = model.focus == Focus::Body;
let border = if focused {
model.theme.border_focus
} else {
model.theme.border
};
let editor_wrap = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
padding: Rect {
left: length(1.0_f32),
right: length(1.0_f32),
top: length(1.0_f32),
bottom: length(1.0_f32),
},
..Default::default()
})
.fill(border)
.radius(4.0)
.on_click(Msg::Focus(Focus::Body))
.children(vec![editor]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(2.0_f32),
},
..Default::default()
})
.children(vec![label_node, editor_wrap])
}
pub(crate) fn stats_view(model: &Model) -> View<Msg> {
let Some(id) = model.selected else {
return View::new(Style::default());
};
let fwd = model.store.forward_links(id);
let back = model.store.backlinks(id);
let fwd_titles: Vec<String> = fwd
.iter()
.filter_map(|i| model.store.get(*i).map(|n| n.title.clone()))
.collect();
let back_titles: Vec<String> = back
.iter()
.filter_map(|i| model.store.get(*i).map(|n| n.title.clone()))
.collect();
let nearest: Vec<String> = model
.field
.nearest(id, 3)
.into_iter()
.filter_map(|(nid, score)| {
model
.store
.get(nid)
.map(|n| format!("{} ({:.2})", n.title, score))
})
.collect();
let mut lines = vec![
format!("→ enlaza a: {}", join_or_dash(&fwd_titles)),
format!("← backlinks: {}", join_or_dash(&back_titles)),
format!(" vecinos: {}", join_or_dash(&nearest)),
];
// Procedencia: si la nota llegó por compartir, lleva una etiqueta
// `de:<autor>`. La mostramos explícita.
if let Some(n) = model.store.get(id) {
let autores: Vec<&str> = n
.tags
.iter()
.filter_map(|t| t.strip_prefix("de:"))
.collect();
if !autores.is_empty() {
lines.push(format!("✎ de: {}", autores.join(", ")));
}
}
let nodes: Vec<View<Msg>> = lines
.into_iter()
.map(|s| {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(16.0_f32),
},
..Default::default()
})
.text_aligned(s, 11.0, model.theme.fg_muted, Alignment::Start)
})
.collect();
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_shrink: 0.0,
..Default::default()
})
.children(nodes)
}
pub(crate) fn join_or_dash(items: &[String]) -> String {
if items.is_empty() {
"".to_string()
} else {
items.join(", ")
}
}
@@ -0,0 +1,22 @@
[package]
name = "khipu-brahman"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "khipu-brahman — transporte de sobres khipu sobre libp2p (BrahmanNet): stream cifrado /khipu/sobre/1.0.0 + descubrimiento por DHT. El sobre se verifica con khipu-share::open; el transporte no necesita ser confiable."
[dependencies]
khipu-share = { path = "../khipu-share" }
card-net = { workspace = true }
libp2p = { workspace = true }
libp2p-stream = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
futures = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
agora-core = { workspace = true }
+275
View File
@@ -0,0 +1,275 @@
//! `khipu-brahman` — transporte de sobres khipu sobre libp2p, vía la capa
//! P2P compartida [`card_net::BrahmanNet`] (TCP + Noise + Yamux + Kademlia).
//!
//! Es el hermano WAN del transporte LAN de `khipu-share::net`: en vez de un
//! `TcpStream` directo, abre un stream libp2p sobre el protocolo
//! [`SOBRE_PROTOCOL`] y manda el mismo sobre serializado. Como el sobre va
//! firmado y direccionado por contenido, **el transporte no necesita ser
//! confiable**: quien recibe verifica con [`khipu_share::open`] antes de
//! ingerir. El cifrado Noise sólo agrega confidencialidad en tránsito.
//!
//! El descubrimiento es por DHT Kademlia: [`KhipuNode::anunciar`] publica
//! bajo una clave fija y [`KhipuNode::descubrir`] lista a quién la provee —
//! rendezvous sin saber la `Multiaddr` de antemano (hace falta al menos un
//! peer bootstrap en la tabla, vía [`KhipuNode::add_peer`]). La travesía de
//! NAT (relay + dcutr + autonat) **sí** está cableada en `BrahmanNet`
//! (`shared/card/card-net`); ver el test `jalar_a_traves_de_un_relay` en
//! `tests/p2p_roundtrip.rs`, que jala un sobre a través de un circuito relay.
//! Detrás de NAT simétrico todavía conviene un relay público alcanzable.
//!
//! Marco de cable: `u32` big-endian con el largo del sobre + el sobre
//! (postcard). Un sobre por stream — espejo del marco de `khipu-share::net`.
use std::sync::Arc;
use std::time::Duration;
use card_net::{BrahmanNet, Multiaddr, PeerId, Protocol};
use futures::StreamExt;
use khipu_share::SignedBundle;
use libp2p::StreamProtocol;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio_util::compat::FuturesAsyncReadCompatExt;
/// Protocolo de stream libp2p para transferir un sobre khipu.
pub const SOBRE_PROTOCOL: StreamProtocol = StreamProtocol::new("/khipu/sobre/1.0.0");
/// Clave DHT bajo la que los cuadernos khipu se anuncian y se descubren.
const KHIPU_DHT_KEY: &[u8] = b"khipu/sobre/1.0.0";
/// Tope defensivo de un sobre entrante (64 MiB), igual que el transporte LAN.
const MAX_SOBRE: u32 = 64 * 1024 * 1024;
/// Falla del transporte libp2p de sobres.
#[derive(Debug, thiserror::Error)]
pub enum BrahmanError {
#[error("nodo libp2p: {0}")]
Nodo(String),
#[error("abrir stream: {0}")]
Stream(String),
#[error("marco inválido: {0}")]
Marco(String),
#[error("io de red: {0}")]
Io(String),
#[error("sobre ilegible: {0}")]
Sobre(String),
}
/// Un cuaderno khipu en la red P2P. Envoltura fina sobre [`BrahmanNet`]
/// que habla el protocolo de sobres.
pub struct KhipuNode {
net: Arc<BrahmanNet>,
}
impl KhipuNode {
/// Crea un nodo con su propio [`BrahmanNet`] (keypair efímera). Debe
/// llamarse dentro de un runtime de tokio — el swarm corre en una task.
pub fn standalone() -> Result<Self, BrahmanError> {
let net = BrahmanNet::new().map_err(|e| BrahmanError::Nodo(e.to_string()))?;
Ok(Self { net: Arc::new(net) })
}
/// Comparte un [`BrahmanNet`] ya existente (p. ej. el mismo nodo que
/// usan agora/minga), registrando el protocolo de sobres encima.
pub fn sharing(net: Arc<BrahmanNet>) -> Self {
Self { net }
}
/// Identidad de red de este nodo.
pub fn peer_id(&self) -> PeerId {
self.net.peer_id
}
/// Empieza a escuchar en `addr`; devuelve la dirección efectiva.
pub async fn listen(&self, addr: Multiaddr) -> Multiaddr {
self.net.listen(addr).await
}
/// Escucha en una multiaddr dada como texto y devuelve la **dirección
/// para compartir** ya con `/p2p/<peer-id>` — lista para pegarle a un
/// par. Pensada para callers que no quieren tocar tipos de libp2p.
pub async fn listen_str(&self, addr: &str) -> Result<String, BrahmanError> {
let m: Multiaddr = addr
.parse()
.map_err(|e| BrahmanError::Nodo(format!("multiaddr inválida: {e}")))?;
let bound = self.net.listen(m).await;
let s = bound.to_string();
// Una reserva de circuito ya viene con `…/p2p-circuit/p2p/<self>`:
// no le agregamos otro `/p2p/`. Un tcp pelado no trae peer-id, así
// que sí se lo anexamos para que sea una dirección de marcado.
if s.contains("/p2p/") {
Ok(s)
} else {
Ok(format!("{s}/p2p/{}", self.net.peer_id))
}
}
/// Marca a un par para conectarse.
pub fn dial(&self, addr: Multiaddr) {
self.net.dial(addr);
}
/// Marca a un par dado como texto (multiaddr).
pub fn dial_str(&self, addr: &str) -> Result<(), BrahmanError> {
let m: Multiaddr = addr
.parse()
.map_err(|e| BrahmanError::Nodo(format!("multiaddr inválida: {e}")))?;
self.net.dial(m);
Ok(())
}
/// Siembra la tabla DHT con un par conocido (bootstrap para descubrir).
pub fn add_peer(&self, peer: PeerId, addr: Multiaddr) {
self.net.add_dht_peer(peer, addr);
}
/// Anuncia que este nodo sirve un cuaderno khipu (clave DHT fija).
pub fn anunciar(&self) {
self.net.start_providing(KHIPU_DHT_KEY);
}
/// Descubre nodos khipu por DHT (sin incluirse a sí mismo).
pub async fn descubrir(&self) -> Vec<PeerId> {
let mut peers = self.net.find_providers(KHIPU_DHT_KEY).await;
let me = self.net.peer_id;
peers.retain(|p| *p != me);
peers
}
/// Sirve sobres: por cada stream entrante llama a `supply` y manda lo
/// que devuelva (típicamente leer `compartido.khipu`). `None` ⇒ no hay
/// sobre, cierra el stream. Bloqueante: corre en su propia task hasta
/// el shutdown del nodo.
pub fn run_serve<F>(&self, supply: F) -> tokio::task::JoinHandle<()>
where
F: Fn() -> Option<Vec<u8>> + Send + Sync + 'static,
{
let mut control = self.net.control.clone();
tokio::spawn(async move {
let mut incoming = match control.accept(SOBRE_PROTOCOL) {
Ok(i) => i,
Err(_) => return, // ya hay un accept para este protocolo
};
let supply = Arc::new(supply);
while let Some((_peer, stream)) = incoming.next().await {
let supply = Arc::clone(&supply);
tokio::spawn(async move {
if let Some(bytes) = supply() {
let mut compat = stream.compat();
let _ = write_frame(&mut compat, &bytes).await;
}
});
}
})
}
/// Jala el sobre de un par dado por su **dirección de marcado** como
/// texto (`/ip4/.../tcp/.../p2p/<peer-id>`): extrae el peer-id, dial-ea
/// y reintenta el fetch unos segundos mientras se establece la
/// conexión. La forma que usa la app.
pub async fn fetch_addr_str(&self, addr: &str) -> Result<SignedBundle, BrahmanError> {
let m: Multiaddr = addr
.parse()
.map_err(|e| BrahmanError::Nodo(format!("multiaddr inválida: {e}")))?;
let peer = peer_from_multiaddr(&m)
.ok_or_else(|| BrahmanError::Nodo("la multiaddr no trae /p2p/<peer-id>".into()))?;
self.net.dial(m);
let mut intentos = 0u32;
loop {
match self.fetch(peer).await {
Ok(s) => return Ok(s),
Err(_) if intentos < 60 => {
intentos += 1;
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(e) => return Err(e),
}
}
}
/// Jala el sobre de un par dado por su **peer-id** como texto (la forma
/// que devuelve [`descubrir`](Self::descubrir)). Reintenta mientras el
/// swarm establece la conexión usando las direcciones que aprendió por
/// la DHT/identify. La app la usa para jalar de un par descubierto.
pub async fn fetch_peer_str(&self, peer: &str) -> Result<SignedBundle, BrahmanError> {
let pid: PeerId = peer
.parse()
.map_err(|e| BrahmanError::Nodo(format!("peer-id inválido: {e}")))?;
let mut intentos = 0u32;
loop {
match self.fetch(pid).await {
Ok(s) => return Ok(s),
Err(_) if intentos < 60 => {
intentos += 1;
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(e) => return Err(e),
}
}
}
/// Abre un stream a `peer` y jala su sobre. **No lo verifica** — el
/// caller debe pasarlo por [`khipu_share::open`] antes de confiar.
pub async fn fetch(&self, peer: PeerId) -> Result<SignedBundle, BrahmanError> {
let mut control = self.net.control.clone();
let stream = control
.open_stream(peer, SOBRE_PROTOCOL)
.await
.map_err(|e| BrahmanError::Stream(e.to_string()))?;
let mut compat = stream.compat();
let bytes = read_frame(&mut compat).await?;
SignedBundle::from_bytes(&bytes).map_err(|e| BrahmanError::Sobre(format!("{e:?}")))
}
}
/// Extrae el `PeerId` **destino** de una multiaddr: el ÚLTIMO componente
/// `/p2p/<id>`. En una directa (`…/tcp/P/p2p/<peer>`) es el único; en un
/// circuito (`…/p2p/<relay>/p2p-circuit/p2p/<destino>`) es el de después
/// del relay, no el relay.
fn peer_from_multiaddr(addr: &Multiaddr) -> Option<PeerId> {
addr.iter()
.filter_map(|p| match p {
Protocol::P2p(id) => Some(id),
_ => None,
})
.last()
}
async fn write_frame<S>(stream: &mut S, payload: &[u8]) -> Result<(), BrahmanError>
where
S: AsyncWrite + Unpin,
{
if payload.len() as u64 > MAX_SOBRE as u64 {
return Err(BrahmanError::Marco("sobre demasiado grande".into()));
}
stream
.write_all(&(payload.len() as u32).to_be_bytes())
.await
.map_err(|e| BrahmanError::Io(e.to_string()))?;
stream
.write_all(payload)
.await
.map_err(|e| BrahmanError::Io(e.to_string()))?;
stream.flush().await.map_err(|e| BrahmanError::Io(e.to_string()))?;
Ok(())
}
async fn read_frame<S>(stream: &mut S) -> Result<Vec<u8>, BrahmanError>
where
S: AsyncRead + Unpin,
{
let mut len_buf = [0u8; 4];
stream
.read_exact(&mut len_buf)
.await
.map_err(|e| BrahmanError::Io(e.to_string()))?;
let len = u32::from_be_bytes(len_buf);
if len > MAX_SOBRE {
return Err(BrahmanError::Marco(format!("largo {len} excede el tope")));
}
let mut buf = vec![0u8; len as usize];
stream
.read_exact(&mut buf)
.await
.map_err(|e| BrahmanError::Io(e.to_string()))?;
Ok(buf)
}
@@ -0,0 +1,193 @@
//! Integración: transferir un sobre khipu entre dos nodos libp2p reales en
//! localhost. Espeja el molde de `minga-p2p`/`agora-net-brahman`: un nodo
//! escucha y sirve, el otro dial-ea, abre stream y jala; el sobre que llega
//! se verifica con `khipu_share::open`.
use std::time::{Duration, Instant};
use agora_core::Keypair;
use card_net::Multiaddr;
use khipu_brahman::KhipuNode;
use khipu_share::{open, seal, SharedNote};
/// Camino que usa la app: `listen_str` da la dirección para compartir y
/// `fetch_addr_str` la consume (dial + reintento + fetch).
#[tokio::test]
async fn jalar_por_direccion_str_como_la_app() {
let autor = Keypair::from_seed([32u8; 32]);
let sobre = seal(
&autor,
vec![SharedNote {
title: "via str".into(),
body: "listen_str + fetch_addr_str".into(),
tags: vec![],
}],
1,
)
.unwrap();
let bytes = sobre.to_bytes().unwrap();
let server = KhipuNode::standalone().unwrap();
let client = KhipuNode::standalone().unwrap();
let dial = server.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
let _serve = server.run_serve(move || Some(bytes.clone()));
// fetch_addr_str ya reintenta internamente mientras se conecta.
let recibido = client.fetch_addr_str(&dial).await.expect("fetch por str");
let bundle = open(&recibido).expect("verifica tras el viaje");
assert_eq!(bundle.notes[0].title, "via str");
}
/// Descubrimiento por DHT: A publica y se anuncia bajo la clave khipu en
/// la DHT; B —unido a la malla por un rendezvous— la descubre con
/// `descubrir()` y le jala el cuaderno por peer-id, sin conocer su
/// dirección de antemano.
#[tokio::test]
async fn descubrir_por_dht_y_jalar() {
let autor = Keypair::from_seed([34u8; 32]);
let sobre = seal(
&autor,
vec![SharedNote {
title: "via dht".into(),
body: "descubierto por la DHT".into(),
tags: vec![],
}],
1,
)
.unwrap();
let bytes = sobre.to_bytes().unwrap();
// Rendezvous: nodo de la malla al que ambos se conectan.
let rendezvous = KhipuNode::standalone().unwrap();
let r_addr = rendezvous.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
// A publica, se une a la malla y se anuncia en la DHT.
let a = KhipuNode::standalone().unwrap();
let _a_listen = a.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
let _serve = a.run_serve(move || Some(bytes.clone()));
a.dial_str(&r_addr).unwrap();
tokio::time::sleep(Duration::from_secs(2)).await;
a.anunciar();
tokio::time::sleep(Duration::from_secs(1)).await;
// B se une por el rendezvous y descubre a A por DHT.
let b = KhipuNode::standalone().unwrap();
b.dial_str(&r_addr).unwrap();
tokio::time::sleep(Duration::from_secs(2)).await;
let mut peers = Vec::new();
for _ in 0..20 {
peers = b.descubrir().await;
if peers.contains(&a.peer_id()) {
break;
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
assert!(peers.contains(&a.peer_id()), "B debe descubrir a A por DHT");
// B jala de A por su peer-id (la dirección la aprendió por la DHT).
let recibido = tokio::time::timeout(
Duration::from_secs(20),
b.fetch_peer_str(&a.peer_id().to_string()),
)
.await
.expect("el fetch por peer-id no debería colgar")
.expect("recibir por peer-id descubierto");
let bundle = open(&recibido).expect("verifica");
assert_eq!(bundle.notes[0].title, "via dht");
}
/// NAT traversal: A reserva un circuito en un relay público y sirve;
/// B le jala el cuaderno *a través del relay* (Circuit Relay v2), sin
/// dirección directa a A. Verifica la maquinaria relay/dcutr de card-net.
#[tokio::test]
async fn jalar_a_traves_de_un_relay() {
let autor = Keypair::from_seed([33u8; 32]);
let sobre = seal(
&autor,
vec![SharedNote {
title: "relay".into(),
body: "viajó por un circuito relay".into(),
tags: vec![],
}],
1,
)
.unwrap();
let bytes = sobre.to_bytes().unwrap();
// Relay público.
let relay = KhipuNode::standalone().unwrap();
let relay_addr = relay.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
// A: sirve y reserva un circuito en el relay (su dirección pasa a ser
// `…/p2p/<relay>/p2p-circuit/p2p/<A>`).
let a = KhipuNode::standalone().unwrap();
let _serve = a.run_serve(move || Some(bytes.clone()));
// A se conecta al relay; AutoNAT (con el dial-back de A) le confirma al
// relay su dirección externa, necesaria para la reserva. Esperamos a
// que ese sondeo (boot_delay + round-trip) ocurra antes de reservar.
a.dial_str(&relay_addr).unwrap();
tokio::time::sleep(Duration::from_secs(6)).await;
let circuit = format!("{relay_addr}/p2p-circuit");
let a_addr = tokio::time::timeout(Duration::from_secs(15), a.listen_str(&circuit))
.await
.expect("la reserva del circuito no debería colgar")
.unwrap();
assert!(a_addr.contains("p2p-circuit"), "A debe anunciarse vía circuito");
// B jala el cuaderno de A a través del relay.
let b = KhipuNode::standalone().unwrap();
let recibido = tokio::time::timeout(Duration::from_secs(25), b.fetch_addr_str(&a_addr))
.await
.expect("el fetch por relay no debería colgar")
.expect("recibir vía relay");
let bundle = open(&recibido).expect("verifica tras el viaje por relay");
assert_eq!(bundle.notes[0].title, "relay");
}
#[tokio::test]
async fn jalar_un_sobre_entre_dos_nodos_libp2p() {
// Sellar el cuaderno a servir.
let autor = Keypair::from_seed([31u8; 32]);
let sobre = seal(
&autor,
vec![SharedNote {
title: "P2P".into(),
body: "viajó por libp2p".into(),
tags: vec!["brahman".into()],
}],
1,
)
.unwrap();
let bytes = sobre.to_bytes().unwrap();
// Dos nodos en localhost.
let server = KhipuNode::standalone().unwrap();
let client = KhipuNode::standalone().unwrap();
let server_pid = server.peer_id();
let addr = server.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
let _serve = server.run_serve(move || Some(bytes.clone()));
// El cliente dial-ea por multiaddr + peer-id.
let dial: Multiaddr = format!("{addr}/p2p/{server_pid}").parse().unwrap();
client.dial(dial);
// Reintentar el fetch hasta que la conexión esté lista.
let deadline = Instant::now() + Duration::from_secs(8);
let recibido = loop {
match client.fetch(server_pid).await {
Ok(s) => break s,
Err(_) if Instant::now() < deadline => {
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(e) => panic!("fetch falló: {e}"),
}
};
// Idéntico bit a bit y verificable.
assert_eq!(recibido, sobre);
let bundle = open(&recibido).expect("firma válida tras el viaje libp2p");
assert_eq!(bundle.notes[0].title, "P2P");
assert_eq!(bundle.author, autor.public_key());
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "khipu-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "khipu-app — núcleo de toma de notas: el modelo Note, el almacén con etiquetas y búsqueda, y el grafo de wiki-links [[...]] con backlinks."
[dependencies]
serde = { workspace = true }
+21
View File
@@ -0,0 +1,21 @@
# khipu-core
> Modelo de nota + store para [khipu](../README.md). Sin UI.
`Note` lleva contenido, timestamp de creación, timestamp de último acceso, y `mass: f32`. El store es CRUD simple sobre archivos JSON en `$XDG_DATA_HOME/khipu/`. Cada vez que se lee una nota se actualiza su `last_access` (señal que [khipu-gravity](../khipu-gravity/README.md) usa para reforzar).
## API
```rust
use khipu_core::{Note, Store};
let store = Store::open()?;
let id = store.create("nota nueva")?;
let note = store.read(id)?;
store.touch(id)?; // refresca last_access
```
## Deps
- `serde`, `serde_json`
- `directories` para el XDG path
+21
View File
@@ -0,0 +1,21 @@
# khipu-core
> Note model + store for [khipu](../README.md). No UI.
`Note` carries content, creation timestamp, last-access timestamp, and `mass: f32`. The store is simple CRUD over JSON files in `$XDG_DATA_HOME/khipu/`. Every read updates `last_access` (the signal [khipu-gravity](../khipu-gravity/README.md) uses to reinforce).
## API
```rust
use khipu_core::{Note, Store};
let store = Store::open()?;
let id = store.create("new note")?;
let note = store.read(id)?;
store.touch(id)?; // refresh last_access
```
## Deps
- `serde`, `serde_json`
- `directories` for XDG path
+23
View File
@@ -0,0 +1,23 @@
//! `khipu_app-core` — el núcleo agnóstico de la toma de notas.
//!
//! Una nota es texto con título, etiquetas y enlaces `[[...]]`. El
//! [`NoteStore`] las guarda y deriva el grafo: forward-links, backlinks,
//! huérfanas y enlaces colgantes. Sin UI, sin storage en disco, sin red
//! — tipos puros y deterministas.
//!
//! - [`note`] — el modelo [`Note`].
//! - [`links`] — el parser de wiki-links `[[...]]`.
//! - [`store`] — el [`NoteStore`] y el grafo de enlaces.
//!
//! La gravedad semántica (clustering por afinidad de embeddings) vive en
//! `khipu_app-gravity`; las lentes visuales, en los crates de frontend.
#![forbid(unsafe_code)]
pub mod links;
pub mod note;
pub mod store;
pub use links::parse_links;
pub use note::{Note, NoteId};
pub use store::NoteStore;
+93
View File
@@ -0,0 +1,93 @@
//! Parser de wiki-links — los destinos `[[...]]` dentro de una nota.
//!
//! Un solo format: dobles corchetes con el título adentro. El texto se
//! recorta; los enlaces vacíos se descartan; el orden de aparición se
//! conserva y se deduplica (un mismo destino enlazado dos veces cuenta
//! una sola vez como arista).
/// Extrae los destinos `[[...]]` de `text`, recortados y deduplicados,
/// en orden de aparición.
pub fn parse_links(text: &str) -> Vec<String> {
let bytes = text.as_bytes();
let mut out: Vec<String> = Vec::new();
let mut i = 0;
while i + 3 < bytes.len() {
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
if let Some(close) = find_close(text, i + 2) {
let inner = text[i + 2..close].trim();
if !inner.is_empty() && !out.iter().any(|l| l == inner) {
out.push(inner.to_string());
}
i = close + 2;
continue;
}
}
// Avanza un carácter UTF-8 completo, no un byte.
i += utf8_len(bytes[i]);
}
out
}
/// Posición del `]]` que cierra a partir de `from`, si existe.
fn find_close(text: &str, from: usize) -> Option<usize> {
let bytes = text.as_bytes();
let mut i = from;
while i + 1 < bytes.len() {
if bytes[i] == b']' && bytes[i + 1] == b']' {
return Some(i);
}
// Un `[[` antes del cierre aborta: enlaces anidados no son válidos.
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
return None;
}
i += 1;
}
None
}
/// Largo en bytes del carácter UTF-8 que empieza en `b`.
fn utf8_len(b: u8) -> usize {
match b {
0x00..=0x7F => 1,
0xC0..=0xDF => 2,
0xE0..=0xEF => 3,
_ => 4,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_simple_links() {
let links = parse_links("ver [[Cocina]] y también [[Jardín]].");
assert_eq!(links, vec!["Cocina", "Jardín"]);
}
#[test]
fn trims_inner_whitespace() {
assert_eq!(parse_links("[[ Taller ]]"), vec!["Taller"]);
}
#[test]
fn empty_links_are_dropped() {
assert_eq!(parse_links("[[]] y [[ ]]"), Vec::<String>::new());
}
#[test]
fn duplicates_collapse_to_one() {
assert_eq!(parse_links("[[A]] [[B]] [[A]]"), vec!["A", "B"]);
}
#[test]
fn unclosed_bracket_is_ignored() {
assert_eq!(parse_links("texto [[sin cerrar"), Vec::<String>::new());
}
#[test]
fn handles_unicode_content_around_links() {
let links = parse_links("café ☕ con [[Niños]] — añoño");
assert_eq!(links, vec!["Niños"]);
}
}
+108
View File
@@ -0,0 +1,108 @@
//! El modelo `Note` — la unidad de khipu_app.
use serde::{Deserialize, Serialize};
use crate::links::parse_links;
/// Identificador de una nota. Lo asigna el almacén, monótono y estable.
pub type NoteId = u64;
/// Una nota: título, cuerpo, etiquetas y marcas de tiempo. Los enlaces
/// no se guardan aparte — se derivan del cuerpo bajo demanda.
///
/// `mass` y `last_access` son la señal temporal: la masa decae con el
/// tiempo y `last_access` registra cuándo fue la última lectura. La
/// física la aplica el caller (típicamente con `khipu-gravity`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Note {
pub id: NoteId,
pub title: String,
pub body: String,
pub tags: Vec<String>,
/// Segundo Unix de creación.
pub created_at: u64,
/// Segundo Unix de la última edición.
pub updated_at: u64,
/// Segundo Unix del último acceso (lectura). Defecto: `created_at`
/// en notas nuevas; en payloads viejos sin el campo, `0` y el
/// caller decide cómo arrancar la masa.
#[serde(default)]
pub last_access: u64,
/// Masa de la nota. 1.0 al crearse; decae con el tiempo, sube con
/// cada acceso. Cuando cae bajo el umbral del horizonte la nota
/// pasa al archivo (no se borra). En payloads viejos arranca en 1.0.
#[serde(default = "default_mass")]
pub mass: f32,
/// Posición ancla en el lienzo del mapa, en coordenadas de mundo.
/// Se fija **una sola vez** cuando la nota se coloca (por gravedad
/// semántica, contra las notas ya asentadas) y se persiste: el mapa
/// es estable — un pensamiento conserva su domicilio para que la
/// memoria espacial pueda recordarlo. `None` = todavía sin colocar
/// (el caller le asigna lugar la primera vez que la pinta). En
/// payloads viejos arranca en `None` y se coloca al cargar.
#[serde(default)]
pub pos: Option<(f32, f32)>,
}
fn default_mass() -> f32 {
1.0
}
impl Note {
/// Destinos `[[...]]` que el cuerpo de la nota referencia.
pub fn outgoing_links(&self) -> Vec<String> {
parse_links(&self.body)
}
/// `true` si la nota lleva la etiqueta `tag` (sin distinguir mayúsculas).
pub fn has_tag(&self, tag: &str) -> bool {
self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))
}
/// `true` si `query` aparece en el título o el cuerpo (sin distinguir
/// mayúsculas).
pub fn matches(&self, query: &str) -> bool {
let q = query.to_lowercase();
self.title.to_lowercase().contains(&q) || self.body.to_lowercase().contains(&q)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn note(title: &str, body: &str) -> Note {
Note {
id: 1,
title: title.into(),
body: body.into(),
tags: vec!["casa".into()],
created_at: 0,
updated_at: 0,
last_access: 0,
mass: 1.0,
pos: None,
}
}
#[test]
fn outgoing_links_reads_the_body() {
let n = note("Cocina", "preparar con [[Horno]] y [[Cuchillos]]");
assert_eq!(n.outgoing_links(), vec!["Horno", "Cuchillos"]);
}
#[test]
fn has_tag_is_case_insensitive() {
let n = note("x", "y");
assert!(n.has_tag("CASA"));
assert!(!n.has_tag("trabajo"));
}
#[test]
fn matches_searches_title_and_body() {
let n = note("Lista de mercado", "comprar pan");
assert!(n.matches("MERCADO"));
assert!(n.matches("pan"));
assert!(!n.matches("ausente"));
}
}
+348
View File
@@ -0,0 +1,348 @@
//! `NoteStore` — el almacén de notas y su grafo de enlaces.
//!
//! Guarda las notas en un `BTreeMap` para que toda iteración sea
//! determinista (ordenada por id). Los enlaces se resuelven por título
//! sin distinguir mayúsculas: `[[cocina]]` y `[[Cocina]]` apuntan a la
//! misma nota.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::note::{Note, NoteId};
/// El almacén de notas de khipu_app.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NoteStore {
notes: BTreeMap<NoteId, Note>,
/// Siguiente id a asignar — monótono, nunca reutiliza huecos.
next_id: NoteId,
}
impl NoteStore {
pub fn new() -> Self {
Self { notes: BTreeMap::new(), next_id: 1 }
}
/// Crea una nota y devuelve su id. Empieza con `mass = 1.0` y
/// `last_access = now` — recién nacida, plenamente visible.
pub fn create(
&mut self,
title: impl Into<String>,
body: impl Into<String>,
tags: Vec<String>,
now: u64,
) -> NoteId {
let id = self.next_id;
self.next_id += 1;
self.notes.insert(
id,
Note {
id,
title: title.into(),
body: body.into(),
tags,
created_at: now,
updated_at: now,
last_access: now,
mass: 1.0,
pos: None,
},
);
id
}
pub fn len(&self) -> usize {
self.notes.len()
}
pub fn is_empty(&self) -> bool {
self.notes.is_empty()
}
pub fn get(&self, id: NoteId) -> Option<&Note> {
self.notes.get(&id)
}
pub fn get_mut(&mut self, id: NoteId) -> Option<&mut Note> {
self.notes.get_mut(&id)
}
/// Itera las notas en orden de id (determinista).
pub fn iter(&self) -> impl Iterator<Item = &Note> {
self.notes.values()
}
/// Reemplaza el cuerpo de una nota y actualiza su marca de tiempo.
/// También marca `last_access` — editar cuenta como acceso.
/// `false` si la nota no existe.
pub fn update_body(&mut self, id: NoteId, body: impl Into<String>, now: u64) -> bool {
match self.notes.get_mut(&id) {
Some(n) => {
n.body = body.into();
n.updated_at = now;
n.last_access = now;
true
}
None => false,
}
}
/// Marca `last_access = now`. La señal que `khipu-gravity` usa para
/// reforzar la masa. No-op si la nota no existe; `true` si tocó.
pub fn touch(&mut self, id: NoteId, now: u64) -> bool {
match self.notes.get_mut(&id) {
Some(n) => {
n.last_access = now;
true
}
None => false,
}
}
/// Asigna directamente la masa de una nota. La física vive en
/// `khipu-gravity`; el store sólo persiste el resultado.
pub fn set_mass(&mut self, id: NoteId, mass: f32) -> bool {
match self.notes.get_mut(&id) {
Some(n) => {
n.mass = mass;
true
}
None => false,
}
}
/// Fija la posición ancla de una nota en el lienzo. Se llama una sola
/// vez al colocarla; de ahí en más el domicilio del pensamiento no se
/// mueve salvo que el usuario lo arrastre. `false` si la nota no existe.
pub fn set_pos(&mut self, id: NoteId, x: f32, y: f32) -> bool {
match self.notes.get_mut(&id) {
Some(n) => {
n.pos = Some((x, y));
true
}
None => false,
}
}
/// Elimina una nota y la devuelve.
pub fn remove(&mut self, id: NoteId) -> Option<Note> {
self.notes.remove(&id)
}
/// Notas que llevan la etiqueta `tag`, en orden de id.
pub fn by_tag(&self, tag: &str) -> Vec<&Note> {
self.notes.values().filter(|n| n.has_tag(tag)).collect()
}
/// Notas cuyo título o cuerpo contienen `query`, en orden de id.
pub fn search(&self, query: &str) -> Vec<&Note> {
self.notes.values().filter(|n| n.matches(query)).collect()
}
/// Ids de las notas cuyo título es `title` (sin distinguir
/// mayúsculas). Pueden ser varias: los títulos no son únicos.
pub fn resolve_title(&self, title: &str) -> Vec<NoteId> {
self.notes
.values()
.filter(|n| n.title.eq_ignore_ascii_case(title))
.map(|n| n.id)
.collect()
}
/// Ids de las notas a las que `id` enlaza por `[[...]]`, resueltas y
/// deduplicadas. Un enlace a un título inexistente simplemente no
/// aporta ningún id (enlace colgante).
pub fn forward_links(&self, id: NoteId) -> Vec<NoteId> {
let Some(note) = self.notes.get(&id) else {
return Vec::new();
};
let mut out: Vec<NoteId> = Vec::new();
for title in note.outgoing_links() {
for target in self.resolve_title(&title) {
if !out.contains(&target) {
out.push(target);
}
}
}
out.sort_unstable();
out
}
/// Ids de las notas que enlazan hacia `id` (backlinks), en orden de id.
pub fn backlinks(&self, id: NoteId) -> Vec<NoteId> {
let Some(target) = self.notes.get(&id) else {
return Vec::new();
};
let title = &target.title;
self.notes
.values()
.filter(|n| {
n.id != id
&& n.outgoing_links()
.iter()
.any(|l| l.eq_ignore_ascii_case(title))
})
.map(|n| n.id)
.collect()
}
/// Notas sin ningún backlink — las islas del grafo.
pub fn orphans(&self) -> Vec<&Note> {
self.notes
.values()
.filter(|n| self.backlinks(n.id).is_empty())
.collect()
}
/// Destinos `[[...]]` que ninguna nota satisface — enlaces colgantes.
pub fn dangling_links(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for note in self.notes.values() {
for title in note.outgoing_links() {
if self.resolve_title(&title).is_empty()
&& !out.iter().any(|t| t.eq_ignore_ascii_case(&title))
{
out.push(title);
}
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Almacén con tres notas enlazadas: Índice → Cocina, Índice → Jardín.
fn seeded() -> (NoteStore, NoteId, NoteId, NoteId) {
let mut s = NoteStore::new();
let indice = s.create("Índice", "ver [[Cocina]] y [[Jardín]]", vec!["meta".into()], 100);
let cocina = s.create("Cocina", "recetas; vuelve al [[Índice]]", vec!["casa".into()], 100);
let jardin = s.create("Jardín", "plantas y riego", vec!["casa".into()], 100);
(s, indice, cocina, jardin)
}
#[test]
fn create_assigns_monotonic_ids() {
let (s, indice, cocina, jardin) = seeded();
assert_eq!((indice, cocina, jardin), (1, 2, 3));
assert_eq!(s.len(), 3);
}
#[test]
fn forward_links_resolve_by_title() {
let (s, indice, cocina, jardin) = seeded();
assert_eq!(s.forward_links(indice), vec![cocina, jardin]);
}
#[test]
fn backlinks_find_incoming_references() {
let (s, indice, cocina, _) = seeded();
// Cocina enlaza al Índice → el Índice tiene a Cocina como backlink.
assert_eq!(s.backlinks(indice), vec![cocina]);
}
#[test]
fn link_resolution_is_case_insensitive() {
let mut s = NoteStore::new();
let a = s.create("Taller", "trabajo", vec![], 0);
let b = s.create("Notas", "voy al [[taller]]", vec![], 0);
assert_eq!(s.forward_links(b), vec![a]);
assert_eq!(s.backlinks(a), vec![b]);
}
#[test]
fn by_tag_filters() {
let (s, _, cocina, jardin) = seeded();
let casa: Vec<_> = s.by_tag("casa").iter().map(|n| n.id).collect();
assert_eq!(casa, vec![cocina, jardin]);
}
#[test]
fn search_scans_title_and_body() {
let (s, _, cocina, _) = seeded();
let hits: Vec<_> = s.search("recetas").iter().map(|n| n.id).collect();
assert_eq!(hits, vec![cocina]);
}
#[test]
fn update_body_changes_links_and_timestamp() {
let (mut s, indice, _, jardin) = seeded();
assert!(s.update_body(indice, "ahora sólo [[Jardín]]", 200));
assert_eq!(s.forward_links(indice), vec![jardin]);
assert_eq!(s.get(indice).unwrap().updated_at, 200);
}
#[test]
fn orphans_have_no_backlinks() {
let (s, _, _, jardin) = seeded();
// Jardín no recibe enlaces de vuelta... pero el Índice sí lo enlaza.
// El único huérfano real sería una nota aislada.
let mut s2 = s;
let aislada = s2.create("Aislada", "sin conexiones", vec![], 0);
let orphan_ids: Vec<_> = s2.orphans().iter().map(|n| n.id).collect();
assert!(orphan_ids.contains(&aislada));
assert!(!orphan_ids.contains(&jardin));
}
#[test]
fn dangling_links_report_missing_targets() {
let mut s = NoteStore::new();
s.create("Nota", "apunta a [[Inexistente]]", vec![], 0);
assert_eq!(s.dangling_links(), vec!["Inexistente"]);
}
#[test]
fn create_initializes_mass_and_last_access() {
let mut s = NoteStore::new();
let id = s.create("x", "y", vec![], 1_700_000_000);
let n = s.get(id).unwrap();
assert_eq!(n.mass, 1.0);
assert_eq!(n.last_access, 1_700_000_000);
}
#[test]
fn touch_refreshes_last_access() {
let mut s = NoteStore::new();
let id = s.create("x", "y", vec![], 100);
assert!(s.touch(id, 500));
assert_eq!(s.get(id).unwrap().last_access, 500);
assert!(!s.touch(9_999, 500));
}
#[test]
fn update_body_also_marks_last_access() {
let mut s = NoteStore::new();
let id = s.create("x", "y", vec![], 100);
assert!(s.update_body(id, "z", 700));
assert_eq!(s.get(id).unwrap().last_access, 700);
}
#[test]
fn set_mass_persists_the_value() {
let mut s = NoteStore::new();
let id = s.create("x", "y", vec![], 100);
assert!(s.set_mass(id, 0.42));
assert!((s.get(id).unwrap().mass - 0.42).abs() < 1e-6);
}
#[test]
fn set_pos_anchors_the_note_once() {
let mut s = NoteStore::new();
let id = s.create("x", "y", vec![], 100);
assert_eq!(s.get(id).unwrap().pos, None);
assert!(s.set_pos(id, 12.0, -8.0));
assert_eq!(s.get(id).unwrap().pos, Some((12.0, -8.0)));
assert!(!s.set_pos(9_999, 0.0, 0.0));
}
#[test]
fn remove_drops_the_note() {
let (mut s, indice, ..) = seeded();
assert!(s.remove(indice).is_some());
assert_eq!(s.len(), 2);
assert!(s.get(indice).is_none());
}
}
@@ -0,0 +1,15 @@
[package]
name = "khipu-gravity"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "khipu — dos físicas puras: masa temporal (decay/refuerzo con vida media y horizonte) y gravedad semántica (afinidad coseno, vecinos, clústeres y layout 2D)."
[dependencies]
khipu-core = { path = "../khipu-core" }
serde = { workspace = true }
# `libm`: la masa decae con `exp` y queremos mantenernos compatibles
# con futuros consumidores `no_std` (kernel wawa, WASM bare).
libm = { workspace = true }
+20
View File
@@ -0,0 +1,20 @@
# khipu-gravity
> Algoritmo de masa/decay para [khipu](../README.md).
Pura física de notas. Cada nota tiene `mass: f32`. Cada tick (configurable, default 1h) la masa decae: `mass *= exp(-dt / half_life)`. Cada acceso la refuerza: `mass += boost`. Cuando `mass < umbral`, la nota cae del horizonte visible (no se borra, queda en archivo). El cálculo es puro — no toca el store; el caller decide qué hacer con el resultado.
## API
```rust
use khipu_gravity::{Gravity, Params};
let g = Gravity::new(Params::default());
let new_mass = g.decay(mass, dt);
let new_mass = g.reinforce(mass, boost);
```
## Deps
- `libm` para `exp` (sin `std::math` cuando se compila a WASM)
- Cero deps de I/O o tiempo (el caller pasa `dt`)
+20
View File
@@ -0,0 +1,20 @@
# khipu-gravity
> Mass/decay algorithm for [khipu](../README.md).
Pure physics of notes. Each note has `mass: f32`. Every tick (configurable, default 1h) mass decays: `mass *= exp(-dt / half_life)`. Each access reinforces it: `mass += boost`. When `mass < threshold`, the note falls off the visible horizon (not deleted, kept in archive). Pure computation — doesn't touch the store; the caller decides what to do with the result.
## API
```rust
use khipu_gravity::{Gravity, Params};
let g = Gravity::new(Params::default());
let new_mass = g.decay(mass, dt);
let new_mass = g.reinforce(mass, boost);
```
## Deps
- `libm` for `exp` (no `std::math` when compiled to WASM)
- Zero I/O or time deps (caller passes `dt`)
+479
View File
@@ -0,0 +1,479 @@
//! `khipu-gravity` — dos físicas, ambas puras y deterministas.
//!
//! 1. **Masa temporal** ([`Gravity`] + [`Params`]): cada nota tiene una
//! `mass: f32`. Con el tiempo `decay(mass, dt)` la apaga; con cada
//! acceso `reinforce(mass, boost)` la enciende. Bajo un umbral la
//! nota cae al «archivo» — no se borra, sólo deja el horizonte
//! visible. El cálculo no toca el store; el caller decide qué hacer
//! con el resultado.
//! 2. **Gravedad semántica** ([`SemanticField`]): afinidad coseno entre
//! vectores, vecinos, clústeres por umbral y layout 2D dirigido por
//! fuerzas. Las notas afines se atraen, todas se repelen.
//!
//! Las dos están desacopladas — un caller puede usar sólo una. El
//! crate compila sin alcanzar `std::f32::exp` (usa [`libm`]) para
//! mantenerse apto a futuros consumos `no_std` (kernel wawa, WASM).
#![forbid(unsafe_code)]
use khipu_core::NoteId;
use serde::{Deserialize, Serialize};
/// Parámetros de la física temporal de una nota.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Params {
/// Vida media: el `dt` (en segundos) tras el cual una masa se
/// reduce a la mitad sin acceso. Default 7 días.
pub half_life_secs: f32,
/// Incremento de masa por acceso. Default 0.4.
pub boost: f32,
/// Bajo este valor la nota cae al archivo (no se borra).
/// Default 0.10.
pub horizon: f32,
}
impl Default for Params {
fn default() -> Self {
Self {
half_life_secs: 7.0 * 24.0 * 3600.0,
boost: 0.4,
horizon: 0.10,
}
}
}
/// Física temporal: decay con el tiempo, refuerzo con cada acceso.
/// Pura — no toca reloj ni store. El caller pasa `dt` y aplica el
/// resultado donde corresponda.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct Gravity {
pub params: Params,
}
impl Gravity {
pub fn new(params: Params) -> Self {
Self { params }
}
/// Masa decaída tras `dt` segundos sin acceso. `dt` negativo o
/// `half_life_secs <= 0` devuelven la masa intacta.
pub fn decay(&self, mass: f32, dt_secs: f32) -> f32 {
if dt_secs <= 0.0 || self.params.half_life_secs <= 0.0 {
return mass.max(0.0);
}
let k = core::f32::consts::LN_2 / self.params.half_life_secs;
let factor = libm::expf(-k * dt_secs);
(mass * factor).max(0.0)
}
/// Masa tras un acceso — suma el `boost` configurado.
pub fn reinforce(&self, mass: f32) -> f32 {
(mass + self.params.boost).max(0.0)
}
/// `true` si la masa está sobre el horizonte (visible). `false`
/// significa archivo, no borrada.
pub fn is_visible(&self, mass: f32) -> bool {
mass >= self.params.horizon
}
}
/// Una nube de notas con su vector semántico — el dominio de la gravedad.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SemanticField {
/// `(id, vector)` en orden de inserción.
entries: Vec<(NoteId, Vec<f32>)>,
}
/// Posición 2D resultante de una nota tras el layout por gravedad.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct NotePlacement {
pub id: NoteId,
pub x: f32,
pub y: f32,
}
/// Parámetros del layout dirigido por fuerzas.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct GravityConfig {
/// Pasos de relajación.
pub iterations: usize,
/// Fuerza de atracción entre notas afines.
pub attraction: f32,
/// Fuerza de repulsión entre todo par de notas.
pub repulsion: f32,
/// Radio del círculo de posiciones iniciales.
pub radius: f32,
/// Fracción de la fuerza neta que se aplica por paso (amortiguación).
pub step: f32,
}
impl Default for GravityConfig {
fn default() -> Self {
Self {
iterations: 120,
attraction: 0.02,
repulsion: 800.0,
radius: 240.0,
step: 0.85,
}
}
}
/// Similitud coseno de dos vectores. `None` si difieren de largo.
fn cosine(a: &[f32], b: &[f32]) -> Option<f32> {
if a.len() != b.len() {
return None;
}
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if na == 0.0 || nb == 0.0 {
return Some(0.0);
}
Some((dot / (na * nb)).clamp(-1.0, 1.0))
}
impl SemanticField {
pub fn new() -> Self {
Self::default()
}
/// Cantidad de notas en el campo.
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Inserta o reemplaza el vector de una nota.
pub fn insert(&mut self, id: NoteId, vector: Vec<f32>) {
if let Some(slot) = self.entries.iter_mut().find(|(eid, _)| *eid == id) {
slot.1 = vector;
} else {
self.entries.push((id, vector));
}
}
/// Saca la nota del campo. No-op si no estaba. `true` si se removió.
pub fn remove(&mut self, id: NoteId) -> bool {
let before = self.entries.len();
self.entries.retain(|(eid, _)| *eid != id);
self.entries.len() != before
}
/// Itera `(id, &vector)` en orden de inserción. Útil para serializar
/// el campo completo (persistencia, exportar).
pub fn iter(&self) -> impl Iterator<Item = (NoteId, &[f32])> {
self.entries.iter().map(|(id, v)| (*id, v.as_slice()))
}
fn vector_of(&self, id: NoteId) -> Option<&[f32]> {
self.entries
.iter()
.find(|(eid, _)| *eid == id)
.map(|(_, v)| v.as_slice())
}
/// Afinidad (similitud coseno) entre dos notas. `None` si alguna no
/// existe o los vectores difieren de largo.
pub fn affinity(&self, a: NoteId, b: NoteId) -> Option<f32> {
cosine(self.vector_of(a)?, self.vector_of(b)?)
}
/// Las `k` notas más afines a `id`, de mayor a menor afinidad.
/// Empata por id ascendente para que el orden sea determinista.
pub fn nearest(&self, id: NoteId, k: usize) -> Vec<(NoteId, f32)> {
let Some(base) = self.vector_of(id) else {
return Vec::new();
};
let mut scored: Vec<(NoteId, f32)> = self
.entries
.iter()
.filter(|(eid, _)| *eid != id)
.filter_map(|(eid, v)| cosine(base, v).map(|s| (*eid, s)))
.collect();
scored.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(core::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
scored.truncate(k);
scored
}
/// Agrupa las notas en clústeres: dos notas quedan en el mismo grupo
/// si su afinidad alcanza `threshold` (transitivamente). Cada
/// clúster viene ordenado por id, y la lista de clústeres también.
pub fn clusters(&self, threshold: f32) -> Vec<Vec<NoteId>> {
let n = self.entries.len();
let mut parent: Vec<usize> = (0..n).collect();
fn find(parent: &mut [usize], i: usize) -> usize {
let mut root = i;
while parent[root] != root {
root = parent[root];
}
let mut cur = i;
while parent[cur] != root {
let next = parent[cur];
parent[cur] = root;
cur = next;
}
root
}
for i in 0..n {
for j in (i + 1)..n {
let sim = cosine(&self.entries[i].1, &self.entries[j].1).unwrap_or(0.0);
if sim >= threshold {
let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
if ri != rj {
parent[ri] = rj;
}
}
}
}
let mut groups: std::collections::BTreeMap<usize, Vec<NoteId>> = Default::default();
for i in 0..n {
let root = find(&mut parent, i);
groups.entry(root).or_default().push(self.entries[i].0);
}
let mut out: Vec<Vec<NoteId>> = groups.into_values().collect();
for c in &mut out {
c.sort_unstable();
}
out.sort_by(|a, b| a.first().cmp(&b.first()));
out
}
/// Layout 2D por gravedad: las notas afines se atraen, todas se
/// repelen. Determinista — posiciones iniciales en círculo, sin RNG.
pub fn gravity_layout(&self, cfg: &GravityConfig) -> Vec<NotePlacement> {
let n = self.entries.len();
if n == 0 {
return Vec::new();
}
if n == 1 {
return vec![NotePlacement { id: self.entries[0].0, x: 0.0, y: 0.0 }];
}
// Posiciones iniciales repartidas en un círculo.
let mut pos: Vec<(f32, f32)> = (0..n)
.map(|i| {
let a = core::f32::consts::TAU * i as f32 / n as f32;
(cfg.radius * a.cos(), cfg.radius * a.sin())
})
.collect();
// Afinidades precomputadas (no cambian entre pasos).
let mut aff = vec![0.0f32; n * n];
for i in 0..n {
for j in (i + 1)..n {
let s = cosine(&self.entries[i].1, &self.entries[j].1)
.unwrap_or(0.0)
.max(0.0);
aff[i * n + j] = s;
aff[j * n + i] = s;
}
}
const EPS: f32 = 0.001;
for _ in 0..cfg.iterations {
let mut force = vec![(0.0f32, 0.0f32); n];
for i in 0..n {
for j in (i + 1)..n {
let dx = pos[j].0 - pos[i].0;
let dy = pos[j].1 - pos[i].1;
let dist = (dx * dx + dy * dy).sqrt().max(EPS);
let (ux, uy) = (dx / dist, dy / dist);
// Atracción crece con la distancia y la afinidad;
// repulsión cae con el cuadrado de la distancia.
let attract = cfg.attraction * aff[i * n + j] * dist;
let repel = cfg.repulsion / (dist * dist);
let net = attract - repel; // >0 → acercar
force[i].0 += net * ux;
force[i].1 += net * uy;
force[j].0 -= net * ux;
force[j].1 -= net * uy;
}
}
for i in 0..n {
pos[i].0 += force[i].0 * cfg.step;
pos[i].1 += force[i].1 * cfg.step;
}
}
self.entries
.iter()
.zip(pos)
.map(|((id, _), (x, y))| NotePlacement { id: *id, x, y })
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Tres vectores: 1 y 2 casi paralelos, 3 ortogonal.
fn field() -> SemanticField {
let mut f = SemanticField::new();
f.insert(1, vec![1.0, 0.0, 0.0]);
f.insert(2, vec![0.9, 0.1, 0.0]);
f.insert(3, vec![0.0, 0.0, 1.0]);
f
}
#[test]
fn affinity_is_high_for_aligned_vectors() {
let f = field();
let near = f.affinity(1, 2).unwrap();
let far = f.affinity(1, 3).unwrap();
assert!(near > 0.95);
assert!(far.abs() < 1e-6);
assert!(near > far);
}
#[test]
fn affinity_missing_note_is_none() {
assert!(field().affinity(1, 99).is_none());
}
#[test]
fn nearest_ranks_by_affinity() {
let f = field();
let near = f.nearest(1, 2);
assert_eq!(near[0].0, 2); // el más afín a 1
assert_eq!(near.len(), 2);
assert!(near[0].1 > near[1].1);
}
#[test]
fn insert_replaces_existing_vector() {
let mut f = SemanticField::new();
f.insert(1, vec![1.0, 0.0]);
f.insert(1, vec![0.0, 1.0]);
assert_eq!(f.len(), 1);
assert_eq!(f.vector_of(1), Some([0.0, 1.0].as_slice()));
}
#[test]
fn clusters_group_affine_notes() {
let f = field();
// Umbral alto: 1 y 2 juntos, 3 solo.
let cs = f.clusters(0.8);
assert_eq!(cs, vec![vec![1, 2], vec![3]]);
}
#[test]
fn low_threshold_merges_everything() {
let cs = field().clusters(-1.0);
assert_eq!(cs, vec![vec![1, 2, 3]]);
}
#[test]
fn gravity_layout_places_every_note() {
let placements = field().gravity_layout(&GravityConfig::default());
assert_eq!(placements.len(), 3);
let ids: Vec<_> = placements.iter().map(|p| p.id).collect();
assert_eq!(ids, vec![1, 2, 3]);
}
#[test]
fn gravity_pulls_affine_notes_closer() {
let f = field();
let p = f.gravity_layout(&GravityConfig::default());
let dist = |a: NoteId, b: NoteId| {
let pa = p.iter().find(|x| x.id == a).unwrap();
let pb = p.iter().find(|x| x.id == b).unwrap();
((pa.x - pb.x).powi(2) + (pa.y - pb.y).powi(2)).sqrt()
};
// Las notas afines (1,2) terminan más cerca que las disímiles (1,3).
assert!(dist(1, 2) < dist(1, 3));
}
#[test]
fn gravity_layout_is_deterministic() {
let f = field();
let a = f.gravity_layout(&GravityConfig::default());
let b = f.gravity_layout(&GravityConfig::default());
assert_eq!(a, b);
}
#[test]
fn remove_drops_a_note() {
let mut f = field();
assert!(f.remove(2));
assert_eq!(f.len(), 2);
assert!(!f.remove(99));
}
#[test]
fn iter_returns_vectors_in_insertion_order() {
let f = field();
let collected: Vec<_> = f.iter().map(|(id, v)| (id, v.to_vec())).collect();
assert_eq!(collected.len(), 3);
assert_eq!(collected[0].0, 1);
assert_eq!(collected[2].0, 3);
}
#[test]
fn empty_and_single_fields_are_handled() {
assert!(SemanticField::new().gravity_layout(&GravityConfig::default()).is_empty());
let mut one = SemanticField::new();
one.insert(7, vec![1.0, 1.0]);
let p = one.gravity_layout(&GravityConfig::default());
assert_eq!(p, vec![NotePlacement { id: 7, x: 0.0, y: 0.0 }]);
}
}
#[cfg(test)]
mod mass_tests {
use super::*;
#[test]
fn decay_halves_mass_after_one_half_life() {
let g = Gravity::new(Params {
half_life_secs: 1000.0,
..Params::default()
});
let after = g.decay(1.0, 1000.0);
assert!((after - 0.5).abs() < 1e-3);
}
#[test]
fn decay_with_zero_dt_is_a_no_op() {
let g = Gravity::default();
assert!((g.decay(0.7, 0.0) - 0.7).abs() < 1e-6);
}
#[test]
fn decay_negative_dt_returns_input() {
let g = Gravity::default();
assert!((g.decay(0.7, -10.0) - 0.7).abs() < 1e-6);
}
#[test]
fn reinforce_adds_boost() {
let g = Gravity::new(Params { boost: 0.25, ..Params::default() });
assert!((g.reinforce(0.5) - 0.75).abs() < 1e-6);
}
#[test]
fn is_visible_uses_horizon() {
let g = Gravity::new(Params { horizon: 0.1, ..Params::default() });
assert!(g.is_visible(0.10));
assert!(g.is_visible(0.99));
assert!(!g.is_visible(0.05));
}
#[test]
fn long_decay_keeps_mass_non_negative() {
let g = Gravity::default();
assert!(g.decay(1.0, 1_000_000_000.0) >= 0.0);
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "khipu-share"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "khipu-share — sobres de notas portables, firmados Ed25519 y direccionados por contenido (BLAKE3) sobre agora. La gravedad temporal es local: se comparte el contenido, no la atención."
[dependencies]
khipu-core = { path = "../khipu-core" }
agora-core = { workspace = true }
agora-keystore = { workspace = true }
rand = { workspace = true }
blake3 = { workspace = true }
postcard = { workspace = true }
serde = { workspace = true }
serde-big-array = "0.5"
thiserror = { workspace = true }
@@ -0,0 +1,180 @@
//! Descubrimiento de pares khipu en la LAN por baliza UDP.
//!
//! Un cuaderno que publica emite cada pocos segundos una [`Beacon`] —su
//! clave pública, el puerto TCP donde sirve y un nombre— por broadcast de
//! la LAN y por loopback. Quien quiere recibir escucha una ventana corta,
//! junta los pares vistos y le jala el cuaderno al primero (con
//! [`crate::net::fetch`]), sin tener que saber la IP de antemano.
//!
//! `std::net` puro, best-effort: redes que bloquean broadcast no
//! descubren nada, y ahí sigue valiendo apuntar a un par por dirección
//! explícita. El descubrimiento no afloja la seguridad: la baliza sólo
//! dice "dónde", el sobre que llega por TCP se verifica igual con
//! [`crate::open`].
use std::net::{Ipv4Addr, SocketAddr, UdpSocket};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
/// Puerto UDP estándar donde se emiten y escuchan las balizas.
pub const PUERTO_BALIZA: u16 = 7701;
/// Prefijo que marca un datagrama como baliza khipu — descarta tráfico
/// UDP ajeno antes de intentar parsearlo.
const MAGIA: [u8; 4] = *b"KHPU";
/// Lo que un cuaderno anuncia de sí mismo.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Beacon {
/// Clave pública Ed25519 del cuaderno (identidad de quien publica).
pub author: [u8; 32],
/// Puerto TCP donde ese cuaderno sirve el sobre (para `fetch`).
pub port: u16,
/// Nombre legible para mostrar en una lista de pares.
pub name: String,
}
impl Beacon {
/// Serializa la baliza al cable: `MAGIA` seguido del postcard.
pub fn encode(&self) -> Vec<u8> {
let mut out = MAGIA.to_vec();
if let Ok(body) = postcard::to_allocvec(self) {
out.extend_from_slice(&body);
}
out
}
/// Parsea un datagrama. `None` si no lleva la magia o no decodifica —
/// así un paquete UDP cualquiera no nos hace ruido.
pub fn decode(bytes: &[u8]) -> Option<Beacon> {
let body = bytes.strip_prefix(&MAGIA)?;
postcard::from_bytes(body).ok()
}
}
/// Un par visto en la red: dónde jalarle el cuaderno y qué anunció.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerVisto {
/// Dirección TCP de `fetch`: la IP de origen del datagrama + el
/// `port` de la baliza. El que anuncia no necesita conocer su propia
/// IP — la deduce quien recibe.
pub fetch_addr: SocketAddr,
pub beacon: Beacon,
}
/// Emite la baliza una vez: a broadcast de la LAN y a loopback (para que
/// dos cuadernos en la misma máquina se vean). Best-effort — los errores
/// de envío de cada destino se ignoran; sólo falla si no se pudo ni armar
/// el socket emisor.
pub fn anunciar(beacon: &Beacon) -> std::io::Result<()> {
let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
sock.set_broadcast(true)?;
let bytes = beacon.encode();
let _ = sock.send_to(&bytes, (Ipv4Addr::BROADCAST, PUERTO_BALIZA));
let _ = sock.send_to(&bytes, (Ipv4Addr::LOCALHOST, PUERTO_BALIZA));
Ok(())
}
/// Escucha balizas en un socket ya bindeado durante `ventana`, devolviendo
/// los pares vistos (deduplicados por dirección de fetch). Bloqueante.
/// Separar el socket del bind permite testear sobre un puerto efímero.
pub fn escuchar_en(sock: &UdpSocket, ventana: Duration) -> Vec<PeerVisto> {
let _ = sock.set_read_timeout(Some(Duration::from_millis(150)));
let fin = Instant::now() + ventana;
let mut vistos: Vec<PeerVisto> = Vec::new();
let mut buf = [0u8; 2048];
while Instant::now() < fin {
match sock.recv_from(&mut buf) {
Ok((n, origen)) => {
if let Some(beacon) = Beacon::decode(&buf[..n]) {
let fetch_addr = SocketAddr::new(origen.ip(), beacon.port);
if !vistos.iter().any(|p| p.fetch_addr == fetch_addr) {
vistos.push(PeerVisto { fetch_addr, beacon });
}
}
}
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) =>
{
continue
}
Err(_) => break,
}
}
vistos
}
/// Bindea el socket de escucha en el puerto estándar de balizas.
pub fn bind_balizas() -> std::io::Result<UdpSocket> {
UdpSocket::bind((Ipv4Addr::UNSPECIFIED, PUERTO_BALIZA))
}
/// Conveniencia: bindea el puerto estándar y escucha `ventana`.
pub fn descubrir(ventana: Duration) -> std::io::Result<Vec<PeerVisto>> {
Ok(escuchar_en(&bind_balizas()?, ventana))
}
#[cfg(test)]
mod tests {
use super::*;
fn beacon() -> Beacon {
Beacon {
author: [9u8; 32],
port: 7700,
name: "khipu de prueba".into(),
}
}
#[test]
fn encode_decode_roundtrips() {
let b = beacon();
assert_eq!(Beacon::decode(&b.encode()), Some(b));
}
#[test]
fn decode_rejects_non_beacon_traffic() {
assert_eq!(Beacon::decode(b"hola mundo"), None);
assert_eq!(Beacon::decode(b"KHP"), None); // magia truncada
assert_eq!(Beacon::decode(&[]), None);
}
#[test]
fn escuchar_recibe_una_baliza_por_loopback() {
// Escucha en un puerto efímero (no el estándar, para no chocar con
// otros tests ni con un khipu corriendo).
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let destino = listener.local_addr().unwrap();
let b = beacon();
let emisor = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
emisor.send_to(&b.encode(), destino).unwrap();
let vistos = escuchar_en(&listener, Duration::from_millis(500));
assert_eq!(vistos.len(), 1);
// La dirección de fetch combina la IP de origen con el port anunciado.
assert_eq!(vistos[0].fetch_addr.ip(), Ipv4Addr::LOCALHOST);
assert_eq!(vistos[0].fetch_addr.port(), b.port);
assert_eq!(vistos[0].beacon, b);
}
#[test]
fn escuchar_deduplica_balizas_repetidas() {
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let destino = listener.local_addr().unwrap();
let b = beacon();
let emisor = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
// Tres balizas idénticas: un solo par visto.
for _ in 0..3 {
emisor.send_to(&b.encode(), destino).unwrap();
}
let vistos = escuchar_en(&listener, Duration::from_millis(400));
assert_eq!(vistos.len(), 1);
}
}
@@ -0,0 +1,125 @@
//! Identidad del cuaderno, guardada cifrada con [`agora_keystore`].
//!
//! La clave privada (la semilla Ed25519 de 32 bytes) nunca queda en claro
//! en disco: vive cifrada bajo una passphrase (Argon2id + ChaCha20-Poly1305).
//! [`unlock`] la descifra a pedido. Quien tenga acceso de lectura al disco
//! no obtiene la identidad sin la passphrase — el endurecimiento frente al
//! `identidad.seed` en claro de las primeras versiones.
//!
//! Migración: si encuentra una semilla legacy en claro, la cifra dentro
//! del keystore y borra el archivo en claro, conservando la identidad.
use std::path::Path;
use agora_core::Keypair;
use agora_keystore::Keystore;
use rand::RngCore;
/// Falla al desbloquear o crear la identidad.
#[derive(Debug, thiserror::Error)]
pub enum IdentityError {
#[error("keystore: {0}")]
Keystore(String),
#[error("semilla legacy inválida (no son 32 bytes)")]
SemillaLegacy,
}
/// Desbloquea —o crea— la identidad del cuaderno guardada cifrada en
/// `keys_dir`, descifrándola con `passphrase`. Orden de resolución:
///
/// 1. Si el keystore ya tiene una identidad → la descifra. Passphrase
/// incorrecta ⇒ [`IdentityError::Keystore`] (no se distingue de otros
/// fallos del keystore a propósito: no filtramos si existe o no).
/// 2. Si hay una semilla legacy en claro en `legacy_seed` → la **migra**:
/// la cifra en el keystore con `passphrase`, borra el archivo en claro
/// y devuelve esa identidad.
/// 3. Si no hay nada → genera una identidad nueva (semilla de `OsRng`),
/// la guarda cifrada y la devuelve.
pub fn unlock(
keys_dir: &Path,
legacy_seed: Option<&Path>,
passphrase: &str,
) -> Result<Keypair, IdentityError> {
let ks = Keystore::open(keys_dir).map_err(|e| IdentityError::Keystore(e.to_string()))?;
let ids = ks.list().map_err(|e| IdentityError::Keystore(e.to_string()))?;
// 1. Identidad ya guardada: descifrar.
if let Some(id) = ids.first().copied() {
let seed = ks
.load(id, passphrase)
.map_err(|e| IdentityError::Keystore(e.to_string()))?;
return Ok(Keypair::from_seed(seed));
}
// 2. Migrar una semilla legacy en claro, si la hay.
if let Some(path) = legacy_seed {
if let Ok(bytes) = std::fs::read(path) {
let seed =
<[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| IdentityError::SemillaLegacy)?;
let kp = Keypair::from_seed(seed);
ks.save(kp.identity_id(), &seed, passphrase)
.map_err(|e| IdentityError::Keystore(e.to_string()))?;
let _ = std::fs::remove_file(path); // ya está cifrada; fuera el claro
return Ok(kp);
}
}
// 3. Identidad nueva.
let mut seed = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut seed);
let kp = Keypair::from_seed(seed);
ks.save(kp.identity_id(), &seed, passphrase)
.map_err(|e| IdentityError::Keystore(e.to_string()))?;
Ok(kp)
}
#[cfg(test)]
mod tests {
use super::*;
/// Directorio temporal único por nombre de test (evita tempfile como
/// dependencia y las colisiones entre tests del mismo proceso).
fn temp_dir(etiqueta: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("khipu-id-{}-{}", std::process::id(), etiqueta));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn crear_y_desbloquear_da_la_misma_identidad() {
let dir = temp_dir("crear");
let a = unlock(&dir, None, "secreta").unwrap();
let b = unlock(&dir, None, "secreta").unwrap();
assert_eq!(a.public_key(), b.public_key());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn passphrase_incorrecta_falla() {
let dir = temp_dir("malpass");
let _ = unlock(&dir, None, "correcta").unwrap();
assert!(unlock(&dir, None, "incorrecta").is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn migra_semilla_legacy_en_claro() {
let base = temp_dir("migra");
std::fs::create_dir_all(&base).unwrap();
let legacy = base.join("identidad.seed");
let semilla = [7u8; 32];
std::fs::write(&legacy, semilla).unwrap();
let keys = base.join("keys");
let kp = unlock(&keys, Some(&legacy), "secreta").unwrap();
// Conserva la identidad de la semilla legacy.
assert_eq!(kp.public_key(), Keypair::from_seed(semilla).public_key());
// El archivo en claro fue borrado.
assert!(!legacy.exists());
// Y ahora se descifra del keystore, sin legacy.
let kp2 = unlock(&keys, None, "secreta").unwrap();
assert_eq!(kp2.public_key(), kp.public_key());
let _ = std::fs::remove_dir_all(&base);
}
}
+349
View File
@@ -0,0 +1,349 @@
//! `khipu-share` — compartir notas por la red soberana de gioser sin
//! perder su gravedad local.
//!
//! Lo que viaja es el **contenido** de la nota (título, cuerpo, etiquetas),
//! nunca su física temporal: la masa y el último acceso son la atención
//! *de quien tiene la nota*, no una propiedad transferible. Al importar,
//! cada nota nace fresca (`mass = 1.0`, `last_access = now`) — su gravedad
//! arranca en el cuaderno que la recibe.
//!
//! El sobre es:
//! - **direccionado por contenido**: su identidad es `BLAKE3(postcard(bundle))`,
//! así dos sobres con las mismas notas tienen el mismo hash;
//! - **firmado Ed25519** sobre ese hash con la clave del autor, vía
//! [`agora_core`] — verificable sin autoridad central ni red.
//!
//! ```
//! use agora_core::Keypair;
//! use khipu_share::{seal, open, SharedNote};
//!
//! let kp = Keypair::from_seed([7u8; 32]);
//! let notas = vec![SharedNote {
//! title: "Receta".into(),
//! body: "sopa; ver [[Mercado]]".into(),
//! tags: vec!["cocina".into()],
//! }];
//! let sobre = seal(&kp, notas, 1_700_000_000).unwrap();
//!
//! // El receptor verifica firma + hash antes de confiar en el contenido.
//! let bundle = open(&sobre).unwrap();
//! assert_eq!(bundle.notes[0].title, "Receta");
//! ```
#![forbid(unsafe_code)]
pub mod discovery;
pub mod identity;
pub mod net;
use std::collections::HashSet;
use agora_core::{verify_signature, Keypair};
use khipu_core::{Note, NoteId, NoteStore};
use serde::{Deserialize, Serialize};
use serde_big_array::BigArray;
/// El contenido transferible de una nota — sin id, masa ni timestamps.
/// La gravedad temporal se queda en el cuaderno de origen.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SharedNote {
pub title: String,
pub body: String,
pub tags: Vec<String>,
}
impl SharedNote {
/// Proyecta una [`Note`] local a su contenido compartible, descartando
/// id, masa y marcas de tiempo.
pub fn from_note(n: &Note) -> Self {
Self {
title: n.title.clone(),
body: n.body.clone(),
tags: n.tags.clone(),
}
}
}
/// El cuerpo firmable: el autor, cuándo se selló, y las notas. Es lo que
/// se serializa y se hashea — el orden de las notas y las etiquetas es
/// significativo para que el hash sea estable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Bundle {
/// Clave pública Ed25519 del autor (32 bytes).
pub author: [u8; 32],
/// Segundo Unix en que se selló el sobre.
pub created_at: u64,
pub notes: Vec<SharedNote>,
}
/// Un [`Bundle`] con la firma del autor sobre su hash de contenido.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedBundle {
pub bundle: Bundle,
/// Firma Ed25519 sobre [`Bundle::content_hash`].
#[serde(with = "BigArray")]
pub signature: [u8; 64],
}
impl Bundle {
/// La dirección de contenido del sobre: `BLAKE3(postcard(self))`. Es
/// también el mensaje que el autor firma.
pub fn content_hash(&self) -> Result<[u8; 32], ShareError> {
let bytes = postcard::to_allocvec(self).map_err(|_| ShareError::Serializacion)?;
Ok(*blake3::hash(&bytes).as_bytes())
}
}
impl SignedBundle {
/// Serializa el sobre a bytes (postcard) para escribirlo a disco o
/// mandarlo por cualquier canal.
pub fn to_bytes(&self) -> Result<Vec<u8>, ShareError> {
postcard::to_allocvec(self).map_err(|_| ShareError::Serializacion)
}
/// Reconstruye un sobre desde bytes. No verifica la firma — para eso
/// está [`open`].
pub fn from_bytes(bytes: &[u8]) -> Result<Self, ShareError> {
postcard::from_bytes(bytes).map_err(|_| ShareError::Serializacion)
}
/// La dirección de contenido del sobre (delegada a [`Bundle::content_hash`]).
pub fn content_address(&self) -> Result<[u8; 32], ShareError> {
self.bundle.content_hash()
}
}
/// Sella las notas en un sobre firmado por `kp`.
pub fn seal(
kp: &Keypair,
notes: Vec<SharedNote>,
created_at: u64,
) -> Result<SignedBundle, ShareError> {
let bundle = Bundle {
author: kp.public_key(),
created_at,
notes,
};
let hash = bundle.content_hash()?;
let signature = kp.sign(&hash);
Ok(SignedBundle { bundle, signature })
}
/// Abre un sobre: recomputa su hash de contenido y verifica que la firma
/// corresponda a la clave del autor declarado. Devuelve el [`Bundle`]
/// verificado, o un error si el contenido fue alterado o la firma no
/// corresponde. Verificación offline — sin autoridad central.
pub fn open(signed: &SignedBundle) -> Result<&Bundle, ShareError> {
let hash = signed.bundle.content_hash()?;
verify_signature(&signed.bundle.author, &hash, &signed.signature)
.map_err(|_| ShareError::FirmaInvalida)?;
Ok(&signed.bundle)
}
/// Resultado de ingerir un sobre en un cuaderno.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ImportOutcome {
/// Ids de las notas efectivamente creadas.
pub created: Vec<NoteId>,
/// Notas omitidas por título ya presente (la importación repetida es
/// inofensiva — no duplica).
pub skipped: usize,
}
/// Inserta las notas de un `bundle` en `store` como notas nuevas: id e
/// historia local frescos, `mass = 1.0`, `last_access = now`. La gravedad
/// arranca en el cuaderno receptor.
///
/// Las notas cuyo título (sin distinguir mayúsculas) ya existe en el
/// cuaderno se omiten, de modo que importar dos veces el mismo sobre no
/// duplica. Los wiki-links `[[Título]]` sobreviven solos: khipu resuelve
/// enlaces por título, así que si las notas enlazadas también se importan,
/// el grafo se rearma sin remapear ids.
///
/// Cada nota importada recibe una etiqueta de procedencia [`tag_de`]
/// (`de:<hex8 del autor>`) — así queda asentado de quién vino sin tocar
/// el modelo `Note` ni la persistencia. Es una etiqueta normal: visible,
/// buscable y removible.
pub fn import_into(store: &mut NoteStore, bundle: &Bundle, now: u64) -> ImportOutcome {
let mut existing: HashSet<String> =
store.iter().map(|n| n.title.to_lowercase()).collect();
let marca = tag_de(&bundle.author);
let mut out = ImportOutcome::default();
for sn in &bundle.notes {
let key = sn.title.to_lowercase();
// Sólo deduplicamos por títulos no vacíos: dos "(sin título)" no
// son la misma nota.
if !sn.title.is_empty() && existing.contains(&key) {
out.skipped += 1;
continue;
}
let mut tags = sn.tags.clone();
if !tags.iter().any(|t| t == &marca) {
tags.push(marca.clone());
}
let id = store.create(sn.title.clone(), sn.body.clone(), tags, now);
existing.insert(key);
out.created.push(id);
}
out
}
/// Prefijo hex (4 bytes / 8 hex) de una clave o hash — identifica un
/// autor de forma legible sin volcar los 32 bytes.
pub fn hex8(bytes: &[u8; 32]) -> String {
bytes[..4].iter().map(|b| format!("{b:02x}")).collect()
}
/// La etiqueta de procedencia para un autor: `de:<hex8>`.
pub fn tag_de(author: &[u8; 32]) -> String {
format!("de:{}", hex8(author))
}
/// Falla al sellar, abrir o transportar un sobre.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ShareError {
#[error("no se pudo (de)serializar el sobre")]
Serializacion,
#[error("la firma no corresponde al contenido y la clave del autor")]
FirmaInvalida,
}
#[cfg(test)]
mod tests {
use super::*;
fn nota(title: &str, body: &str, tags: &[&str]) -> SharedNote {
SharedNote {
title: title.into(),
body: body.into(),
tags: tags.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn seal_then_open_roundtrips() {
let kp = Keypair::from_seed([1u8; 32]);
let notes = vec![nota("A", "cuerpo a", &["x"]), nota("B", "[[A]]", &[])];
let sobre = seal(&kp, notes.clone(), 42).unwrap();
let bundle = open(&sobre).unwrap();
assert_eq!(bundle.author, kp.public_key());
assert_eq!(bundle.created_at, 42);
assert_eq!(bundle.notes, notes);
}
#[test]
fn content_hash_is_stable_and_content_addressed() {
let kp = Keypair::from_seed([2u8; 32]);
let notes = vec![nota("A", "a", &["t"])];
let a = seal(&kp, notes.clone(), 100).unwrap();
let b = seal(&kp, notes, 100).unwrap();
// Mismas notas + mismo autor + mismo instante → mismo hash y firma.
assert_eq!(a.content_address().unwrap(), b.content_address().unwrap());
assert_eq!(a.signature, b.signature);
}
#[test]
fn tampering_with_content_is_rejected() {
let kp = Keypair::from_seed([3u8; 32]);
let mut sobre = seal(&kp, vec![nota("real", "intacto", &[])], 1).unwrap();
// Alteramos el cuerpo sin rehacer la firma.
sobre.bundle.notes[0].body = "manipulado".into();
assert_eq!(open(&sobre), Err(ShareError::FirmaInvalida));
}
#[test]
fn forged_author_is_rejected() {
let autor = Keypair::from_seed([4u8; 32]);
let impostor = Keypair::from_seed([5u8; 32]);
let mut sobre = seal(&autor, vec![nota("n", "c", &[])], 1).unwrap();
// El impostor pone su clave pero no puede producir la firma válida.
sobre.bundle.author = impostor.public_key();
assert_eq!(open(&sobre), Err(ShareError::FirmaInvalida));
}
#[test]
fn bytes_roundtrip_preserves_verification() {
let kp = Keypair::from_seed([6u8; 32]);
let sobre = seal(&kp, vec![nota("n", "c", &["a", "b"])], 9).unwrap();
let bytes = sobre.to_bytes().unwrap();
let recuperado = SignedBundle::from_bytes(&bytes).unwrap();
assert_eq!(recuperado, sobre);
assert!(open(&recuperado).is_ok());
}
#[test]
fn import_creates_fresh_notes_with_local_gravity() {
let kp = Keypair::from_seed([7u8; 32]);
let sobre = seal(&kp, vec![nota("Receta", "sopa", &["cocina"])], 1).unwrap();
let bundle = open(&sobre).unwrap();
let mut store = NoteStore::new();
let out = import_into(&mut store, bundle, 5_000);
assert_eq!(out.created.len(), 1);
assert_eq!(out.skipped, 0);
let n = store.get(out.created[0]).unwrap();
assert_eq!(n.title, "Receta");
// Conserva sus etiquetas y suma la de procedencia.
assert!(n.tags.contains(&"cocina".to_string()));
assert!(n.tags.iter().any(|t| t.starts_with("de:")));
// Gravedad fresca: masa plena y acceso = ahora del receptor.
assert_eq!(n.mass, 1.0);
assert_eq!(n.last_access, 5_000);
assert_eq!(n.created_at, 5_000);
}
#[test]
fn import_marks_author_provenance() {
let kp = Keypair::from_seed([13u8; 32]);
let sobre = seal(&kp, vec![nota("N", "c", &[])], 1).unwrap();
let bundle = open(&sobre).unwrap();
let mut store = NoteStore::new();
let out = import_into(&mut store, bundle, 1);
let n = store.get(out.created[0]).unwrap();
assert!(n.tags.contains(&tag_de(&kp.public_key())));
}
#[test]
fn reimport_skips_existing_titles() {
let kp = Keypair::from_seed([8u8; 32]);
let sobre = seal(
&kp,
vec![nota("A", "a", &[]), nota("B", "b", &[])],
1,
)
.unwrap();
let bundle = open(&sobre).unwrap();
let mut store = NoteStore::new();
let first = import_into(&mut store, bundle, 10);
assert_eq!(first.created.len(), 2);
// Segunda importación del mismo sobre: nada nuevo.
let second = import_into(&mut store, bundle, 20);
assert_eq!(second.created.len(), 0);
assert_eq!(second.skipped, 2);
assert_eq!(store.len(), 2);
}
#[test]
fn wiki_links_survive_import_by_title() {
let kp = Keypair::from_seed([9u8; 32]);
let sobre = seal(
&kp,
vec![
nota("Índice", "ver [[Receta]]", &[]),
nota("Receta", "sopa", &[]),
],
1,
)
.unwrap();
let bundle = open(&sobre).unwrap();
let mut store = NoteStore::new();
let out = import_into(&mut store, bundle, 1);
let indice = out.created[0];
// El enlace [[Receta]] resuelve a la otra nota importada.
assert_eq!(store.forward_links(indice).len(), 1);
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Transporte LAN mínimo para sobres khipu sobre TCP.
//!
//! El sobre ya es firmado y direccionado por contenido, así que el
//! transporte no necesita ser confiable: quien recibe verifica con
//! [`crate::open`] antes de creer nada. Esto es `std::net` puro — sin
//! libp2p ni async — pensado para "jalar el cuaderno de un par en la LAN".
//!
//! Marco de cable: un `u32` big-endian con el largo del sobre, seguido
//! del sobre serializado (postcard). Un sobre por conexión.
use std::io::{self, Read, Write};
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
use crate::{ShareError, SignedBundle};
/// Tope defensivo para un sobre entrante (64 MiB): evita que un par
/// hostil pida un alloc gigante declarando un largo inflado.
const MAX_SOBRE: u32 = 64 * 1024 * 1024;
/// Falla del transporte de sobres.
#[derive(Debug, thiserror::Error)]
pub enum NetError {
#[error("io de red: {0}")]
Io(String),
#[error("marco inválido: {0}")]
Protocolo(String),
#[error(transparent)]
Sobre(#[from] ShareError),
}
impl From<io::Error> for NetError {
fn from(e: io::Error) -> Self {
NetError::Io(e.to_string())
}
}
fn write_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> {
if payload.len() as u64 > MAX_SOBRE as u64 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"sobre demasiado grande para el marco",
));
}
stream.write_all(&(payload.len() as u32).to_be_bytes())?;
stream.write_all(payload)?;
stream.flush()
}
fn read_frame(stream: &mut TcpStream) -> Result<Vec<u8>, NetError> {
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf)?;
let len = u32::from_be_bytes(len_buf);
if len > MAX_SOBRE {
return Err(NetError::Protocolo(format!(
"largo declarado {len} excede el tope"
)));
}
let mut buf = vec![0u8; len as usize];
stream.read_exact(&mut buf)?;
Ok(buf)
}
/// Conecta a `addr`, lee un sobre y lo deserializa. **No lo verifica** —
/// el caller debe pasar el resultado por [`crate::open`] antes de confiar.
pub fn fetch(addr: impl ToSocketAddrs) -> Result<SignedBundle, NetError> {
let mut stream = TcpStream::connect(addr)?;
let bytes = read_frame(&mut stream)?;
Ok(SignedBundle::from_bytes(&bytes)?)
}
/// Atiende una sola conexión: manda `payload` y vuelve. Útil para tests
/// y para un "compartir una vez".
pub fn serve_once(listener: &TcpListener, payload: &[u8]) -> io::Result<()> {
let (mut stream, _) = listener.accept()?;
write_frame(&mut stream, payload)
}
/// Atiende conexiones para siempre. Por cada una llama a `supply` para
/// obtener los bytes a mandar — típicamente leer `compartido.khipu` del
/// disco, así sirve siempre la versión vigente. Una conexión cuyo
/// `supply` falla (todavía no hay sobre, p. ej.) se salta sin tumbar el
/// servidor. Bloqueante: pensado para correr en su propio hilo.
pub fn serve_loop<F>(listener: TcpListener, supply: F)
where
F: Fn() -> io::Result<Vec<u8>>,
{
for conn in listener.incoming() {
let Ok(mut stream) = conn else { continue };
if let Ok(bytes) = supply() {
let _ = write_frame(&mut stream, &bytes);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{open, seal, SharedNote};
use agora_core::Keypair;
fn nota(title: &str, body: &str) -> SharedNote {
SharedNote {
title: title.into(),
body: body.into(),
tags: Vec::new(),
}
}
#[test]
fn fetch_recovers_a_served_bundle_and_verifies() {
let kp = Keypair::from_seed([11u8; 32]);
let sobre = seal(&kp, vec![nota("Red", "hola por TCP")], 1).unwrap();
let bytes = sobre.to_bytes().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || serve_once(&listener, &bytes).unwrap());
let recibido = fetch(addr).unwrap();
server.join().unwrap();
assert_eq!(recibido, sobre);
// El sobre que llegó por la red verifica firma + hash.
let bundle = open(&recibido).unwrap();
assert_eq!(bundle.notes[0].title, "Red");
}
#[test]
fn serve_loop_serves_the_current_supply_each_time() {
let kp = Keypair::from_seed([12u8; 32]);
let sobre = seal(&kp, vec![nota("A", "a")], 1).unwrap();
let bytes = sobre.to_bytes().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
// El servidor corre en su hilo; lo dejamos colgado al terminar el
// test (los dos fetch ya probaron lo que importa).
std::thread::spawn(move || {
serve_loop(listener, move || Ok(bytes.clone()));
});
// Dos pares distintos jalan el mismo cuaderno.
let a = fetch(addr).unwrap();
let b = fetch(addr).unwrap();
assert_eq!(a, sobre);
assert_eq!(b, sobre);
}
#[test]
fn fetch_against_nothing_is_an_error_not_a_panic() {
// Puerto cerrado: connect falla, devolvemos NetError::Io.
let err = fetch("127.0.0.1:1").unwrap_err();
assert!(matches!(err, NetError::Io(_)));
}
}
@@ -0,0 +1,75 @@
//! Integración: el camino completo de compartir por LAN, todo en loopback.
//!
//! Sella un cuaderno → lo sirve por TCP → anuncia su baliza → otro par lo
//! descubre por UDP, le jala el sobre y lo verifica. Es la cadena que
//! ejercitan los botones «publicar» y «recibir» de khipu-app, sin GUI.
use std::net::{Ipv4Addr, TcpListener, UdpSocket};
use std::time::Duration;
use agora_core::Keypair;
use khipu_core::NoteStore;
use khipu_share::discovery::{escuchar_en, Beacon};
use khipu_share::{net, open, seal, SharedNote};
#[test]
fn descubrir_jalar_y_verificar_de_punta_a_punta() {
// --- Lado que publica ---
let autor = Keypair::from_seed([21u8; 32]);
let sobre = seal(
&autor,
vec![SharedNote {
title: "Compartida por LAN".into(),
body: "viajó por descubrimiento + TCP".into(),
tags: vec!["red".into()],
}],
100,
)
.unwrap();
let bytes = sobre.to_bytes().unwrap();
// Sirve el sobre por TCP en un puerto efímero.
let tcp = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let tcp_port = tcp.local_addr().unwrap().port();
std::thread::spawn(move || {
let _ = net::serve_once(&tcp, &bytes);
});
// Anuncia la baliza apuntando a ese puerto TCP.
let beacon = Beacon {
author: autor.public_key(),
port: tcp_port,
name: "khipu publicador".into(),
};
// --- Lado que recibe ---
// Escucha balizas en un puerto UDP efímero (en la app sería el
// estándar 7701); el publicador le manda la suya por loopback.
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let destino = listener.local_addr().unwrap();
let emisor = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
emisor.send_to(&beacon.encode(), destino).unwrap();
// Descubre al par y le jala el cuaderno.
let pares = escuchar_en(&listener, Duration::from_millis(500));
assert_eq!(pares.len(), 1, "debería haber descubierto un par");
let par = &pares[0];
assert_eq!(par.fetch_addr.port(), tcp_port);
assert_eq!(par.beacon.author, autor.public_key());
let recibido = net::fetch(par.fetch_addr).unwrap();
// Verifica firma + hash e ingiere como nota fresca.
let bundle = open(&recibido).unwrap();
let mut store = NoteStore::new();
let resultado = khipu_share::import_into(&mut store, bundle, 9_000);
assert_eq!(resultado.created.len(), 1);
let nota = store.get(resultado.created[0]).unwrap();
assert_eq!(nota.title, "Compartida por LAN");
assert!(nota.tags.contains(&"red".to_string()));
// La nota lleva la procedencia del autor que la selló.
assert!(nota.tags.contains(&khipu_share::tag_de(&autor.public_key())));
// Gravedad fresca en el receptor.
assert_eq!(nota.mass, 1.0);
assert_eq!(nota.last_access, 9_000);
}
Generated
+6692
View File
File diff suppressed because it is too large Load Diff
+439
View File
@@ -0,0 +1,439 @@
# Cargo.toml raíz STANDALONE de khipu — front-door sobre Llimphi.
# Solo el código de khipu; Llimphi y lo fundacional por git-dep del monorepo gioser.git.
[workspace]
resolver = "2"
members = [
"00_unanchay/khipu/khipu-app",
"00_unanchay/khipu/khipu-brahman",
"00_unanchay/khipu/khipu-core",
"00_unanchay/khipu/khipu-gravity",
"00_unanchay/khipu/khipu-share",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.80"
license = "MIT"
authors = ["Sergio <gerencia@jlsoltech.com>"]
publish = false
repository = "https://gitea.gioser.net/sergio/khipu"
[workspace.dependencies]
# === Registro de apps / menú global ===
app-bus = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === Serialización ===
serde = { version = "1", features = ["derive"] }
serde_json = "1"
lsp-types = "0.97"
serde-big-array = "0.5"
postcard = { version = "1", features = ["use-std"] }
toml = "0.8"
ron = "0.8"
bincode = "1"
base64 = "0.22"
# === Errores ===
thiserror = "2" # bump uniforme; arje (era 1) puede requerir ajustes menores
anyhow = "1"
# === Async ===
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["compat"] }
async-trait = "0.1"
futures = "0.3"
# === Observabilidad ===
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# === Linux primitives (arje) ===
nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] }
libc = "0.2"
# === IDs / Hash / Crypto ===
ulid = { version = "1", features = ["serde"] }
uuid = { version = "1", features = ["v4", "rng-getrandom"] }
sha2 = "0.10"
blake3 = "1.5"
ed25519-dalek = "2"
aes-gcm = "0.10"
chacha20poly1305 = "0.10"
argon2 = "0.5"
rand = "0.8"
# === WASM (arje) ===
# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para
# que el ABI WASM del host sea idéntico en Linux y en bare-metal.
wasmi = "1.0"
wat = "1"
# === Storage / DB ===
sled = "0.34"
rusqlite = { version = "0.31", features = ["bundled", "blob"] }
# === Ingesta de documentos (iniy-ingest: PDF / EPUB) ===
pdf-extract = "0.7"
epub = "2.1"
# === Bulk import Wikipedia (iniy-wiki dump) ===
bzip2 = "0.4"
# === Compresión (minga multi-bundle) ===
zstd = "0.13"
# === HTTP server (iniy-server) ===
axum = "0.7"
tower = "0.5"
# === ANN sobre embeddings (iniy nli --ann) ===
instant-distance = "0.6"
# === P2P (minga) ===
libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] }
libp2p-stream = "=0.4.0-alpha"
libp2p-allow-block-list = "0.6"
# === SSH (ssh, sandokan RemoteEngine, matilda) ===
russh = "0.54"
# === Math determinista cross-platform (dominium) ===
libm = "0.2"
# === SMF (takiy-midi) ===
# midly: parser/emitter SMF tipo 0/1, no_std-friendly, sin allocs en hot path.
midly = "0.5"
# === Code parsing (minga) ===
arboard = "3"
ropey = "1.6"
tree-sitter = "0.24"
tree-sitter-rust = "0.23"
tree-sitter-python = "0.23"
tree-sitter-typescript = "0.23"
tree-sitter-javascript = "0.23"
tree-sitter-go = "0.23"
# === FS notify ===
notify = "6.1"
# === Grafos (iniy, nakui-core ya lo usa directo en 0.6) ===
petgraph = "0.6"
# === Image decoding (nahual-image-viewer-llimphi) ===
# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless).
# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app
# los pide específicamente.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
# === FUSE (minga-vfs) ===
# default-features = false: prescinde de pkg-config/libfuse-dev en build.
# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime).
fuser = { version = "0.15", default-features = false }
# === CLI / auth (minga) ===
clap = { version = "4", features = ["derive"] }
rpassword = "7"
# === PAM (auth-core) ===
pam = "0.8"
# === D-Bus (arje compat) ===
zbus = { version = "4", default-features = false, features = ["tokio"] }
# === Tests ===
tempfile = "3"
# === Llimphi (motor gráfico soberano) ===
# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux.
# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24.
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos).
# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone).
wgpu = "24"
winit = "0.30"
raw-window-handle = "0.6"
pollster = "0.4"
vello = "0.5"
taffy = "0.9"
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
parley = "0.4"
# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps.
llimphi-ui = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Paleta semántica compartida por las apps y los widgets.
llimphi-theme = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Tweens y helpers de animación sobre el bucle Elm.
llimphi-motion = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps.
llimphi-icons = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Widgets reusables sobre llimphi-ui — uno por crate.
llimphi-widget-app-header = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-banner = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-button = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-card = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-clipboard = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-context-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-edit-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-menubar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-list = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-grid = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-slider = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-scroll = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-splitter = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-stat-card = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-tabs = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-command-palette = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-diff-viewer = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-fif = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-file-picker = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-bookmarks = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-mini-map = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-shuma-term = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-symbol-outline = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-plugin-host = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-theme-switcher = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-area = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-editor-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-editor = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-editor-lsp = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-input = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-tiled = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-nodegraph = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-tree = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-navigator = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Sello vectorial wawa (rombo + W implícita + Merkle Core).
llimphi-widget-wawa-mark = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Widgets de elegancia transversal (tooltip, spinner, progress, toast,
# modal, empty, status-bar, shortcuts-help, splash).
llimphi-widget-tooltip = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-spinner = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-progress = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-toast = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-modal = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-empty = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-status-bar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-shortcuts-help = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-timeline = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-splash = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Controles de formulario y signaling (switch, segmented, breadcrumb,
# badge, avatar, skeleton, field).
llimphi-widget-switch = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-segmented = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-dock-rail = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-breadcrumb = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-badge = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-avatar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-skeleton = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-field = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Firma visual transversal (gradient sutil + hairline accent).
llimphi-widget-panel = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-panes = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-workspace = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Abstracción Selector — host (paths) + wawa (khipus).
llimphi-module-selector = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === Filesystem helpers ===
directories = "5"
# === Diff line-based (llimphi-module-diff-viewer) ===
# `similar` es la crate de facto: implementa Myers + Patience + LCS,
# expone `TextDiff` con ChangeTag por línea (Equal/Insert/Delete),
# zero deps fuera de std. La 2.x es estable hace años.
similar = "2"
# === Fuzzy matching (shuma-history) ===
# nucleo-matcher = mismo matcher que helix-editor: rápido, Unicode-correct,
# bonus por prefijos, ranking estable. La versión 0.3 expone el API simple
# que necesitamos (Matcher + Pattern + score).
nucleo-matcher = "0.3"
# === Transporte autenticado (shuma-link) ===
# snow = framework Noise pure-rust. Lo usamos en modo Noise_XK (cliente
# conoce la pubkey del servidor, server descubre la del cliente y la
# valida contra una allowlist). ChaCha20-Poly1305 + X25519 + BLAKE2s.
# La versión 0.9 viene pinneada por libp2p, así nos alineamos.
snow = "0.9"
hex = "0.4"
# === PTY + emulador de terminal (shuma-exec, módulos REPL) ===
# portable-pty aloja un PTY cross-platform; lo usamos para los
# comandos TUI tipo vim/htop/less que necesitan un terminal de verdad.
# vt100 parsea la secuencia de bytes que el PTY emite (ANSI + cursor
# movement + erase + screen state) y mantiene un buffer de pantalla
# renderizable como grid.
portable-pty = "0.9"
vt100 = "0.16"
# === WASM web (gioser) ===
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = "0.3"
glam = "0.30"
# === Markdown (pluma) ===
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
# === Archivos comprimidos (nahual archive viewer) ===
# Sólo listamos el directorio central (nombres/tamaños); no descomprimimos,
# por eso default-features=false alcanza para ZIP. Para tar.gz sí
# descomprimimos en streaming con flate2 (ya declarado arriba), saltando
# los datos de cada entrada — sólo leemos headers.
zip = { version = "2.4", default-features = false }
tar = { version = "0.4", default-features = false }
# === Fuentes (nahual font viewer) ===
# Parseo de TTF/OTF/TTC y extracción de contornos de glifo a paths.
ttf-parser = "0.25"
# ============================================================
# Intra-workspace deps de nahual (referenciadas por workspace = true)
# ============================================================
nahual-text-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-image-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-thumb-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-gallery-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-video-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-card-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-audio-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-tree-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-hex-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-table-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-markdown-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-archive-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-font-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-map-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-geo-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-viewer-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-file-explorer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# ============================================================
# Intra-workspace deps de pineal (módulo de gráficos)
# ============================================================
pineal-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-render = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-cartesian = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-stream = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-mesh = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-financial = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-polar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-heatmap = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-treemap = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-flow = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-phosphor = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-export = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-hexbin = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-contour = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-bars = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# ============================================================
# Intra-workspace deps de iniy (laboratorio semántico de creencias)
# ============================================================
iniy-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-ingest = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-extract = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-nli = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-nli-llm = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-graph = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-store = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === auto: declarados por crates internos faltantes ===
cosmos-coords = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-ephemeris = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-time = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-wcs = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === auto: externas de eternal ===
celestial-eop-data = { version = "0.1"}
approx = "0.5"
byteorder = "1.5"
cc = "1.0"
chrono = "0.4"
crc32fast = "1.4"
criterion = "0.5"
csv = "1.4"
flate2 = "1.0"
glob = "0.3"
indicatif = "0.18"
lz4_flex = "0.11"
memmap2 = "0.9"
mockito = "1.0"
ndarray = "0.15"
num-traits = "0.2"
once_cell = "1.19"
parking_lot = "0.12"
png = "0.18"
proptest = "1.4"
quick-xml = "0.31"
rayon = "1.8"
regex = "1.11"
reqwest = "0.12"
tiff = "0.11"
wide = "0.7"
wiremock = "0.6"
# === i18n (rimay-localize) ===
fluent-bundle = "0.15"
unic-langid = { version = "0.9", features = ["macros"] }
sys-locale = "0.3"
# === Servo (puriy-engine) ===
# Crates publicados de Servo embebibles individualmente. html5ever/markup5ever
# ya entran via ammonia→surrealdb→nakui, así que alineamos versión para no
# duplicar el árbol. markup5ever_rcdom es el DOM Rc-based simple (suficiente
# para Fase 2: parsear y renderizar, sin scripting). cssparser es el tokenizer
# CSS de Stylo, sirve para inline styles. ureq = HTTP síncrono minimalista,
# evita pull de tokio en el engine.
html5ever = "0.39"
markup5ever = "0.39"
markup5ever_rcdom = "0.39"
cssparser = "0.35"
url = "2"
ureq = { version = "2", default-features = false, features = ["tls"] }
# === takiy-synth (SoundFont MIDI) ===
# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador
# feo de takiy-synth por muestras reales (FluidR3, GeneralUser GS, etc).
rustysynth = "1.3"
# === takiy-playback (audio device output) ===
# cpal = backend de audio cross-platform (ALSA/PulseAudio/Pipewire en
# Linux, WASAPI en Windows, CoreAudio en macOS). Lo usamos sólo para
# abrir el device default y empujar muestras f32 — nada de mezclado
# ni efectos en el callback.
cpal = "0.15"
# === media-source-wav (decoder PCM en disco) ===
# hound = lector/escritor WAV puro-Rust, sin deps nativas. Soporta PCM
# entero (8/16/24/32) y float (32). Suficiente para abrir samples y
# stems de prueba sin meter ffmpeg/symphonia.
hound = "3.5"
# === media-source-{mp3,flac,vorbis} (decoders vía symphonia) ===
# symphonia es una colección de decoders puro-Rust mantenida. `mp3` cubre
# media-source-mp3; `flac` (decoder + demuxer FLAC nativo) cubre
# media-source-flac (lossless); `vorbis` + `ogg` (codec + demuxer Ogg)
# cubren media-source-vorbis (lossy clásico, libre de patentes). Sin aac:
# ese tier patentado entra por shared/foreign-av.
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] }
# === media-source-opus (decoder Opus NATIVO puro-Rust) ===
# Opus es el formato de audio nativo de gioser (par del video AV1). ogg
# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus
# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video.
ogg = "0.9"
opus-wave = "3"
# === media-source-webm (demux nativo Matroska/WebM) ===
# matroska-demuxer es un demuxer puro-Rust de MKV/WebM (EBML). Saca los
# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1
# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo.
matroska-demuxer = "0.7"
# === git-deps al monorepo (agregados por la extracción) ===
agora-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
agora-keystore = { git = "https://gitea.gioser.net/sergio/gioser.git" }
card-net = { git = "https://gitea.gioser.net/sergio/gioser.git" }
rimay-verbo = { git = "https://gitea.gioser.net/sergio/gioser.git" }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sergio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11
View File
@@ -0,0 +1,11 @@
# khipu
> Sovereign P2P "notes to oblivion" — local-first, content-addressed, in Rust.
`khipu` (Quechua: *knotted-cord record*) is a sovereign note system: local-first, content-addressed, synced peer-to-peer over the `card`/`chasqui` layer (LAN + WAN + relay/NAT traversal, UDP & DHT discovery, encrypted identity). Notes are designed to be let go of as much as kept — a record that forgets on purpose.
## How dependencies work
Front-door repo: only `khipu-*` crates here. Identity (`card`), networking (`chasqui`) and shared leaves are git-dependencies of the [`gioser`](https://gitea.gioser.net/sergio/gioser) monorepo.
## License
MIT. Part of the [gioser](https://gitea.gioser.net/sergio/gioser) suite.