diff --git a/.ai/prompts/km-thread-reply-trigger.md b/.ai/prompts/km-thread-reply-trigger.md new file mode 100644 index 000000000..21fe50edf --- /dev/null +++ b/.ai/prompts/km-thread-reply-trigger.md @@ -0,0 +1,98 @@ +# Task: KM listens to thread replies (no explicit mention needed) + conversation memory + +## Context + +Repo: `/Users/horacioh/jean/Seed/seed-km` (branch `knowledge-agent-server-setup`). +Working dir for this work: `seed-knowledge-manager/agent/mcp/seed-cli-mcp/`. +Stack: TypeScript, Bun-built bundles deployed to systemd on `ubuntu@oc.hyper.media`. + +The Knowledge Manager agent polls the Seed Hypermedia activity feed every 30s and replies to comments that @mention it. Today, **only comments containing an explicit `@KM` (Embed annotation OR inline `@[name](hm://kmAccountId)`) trigger a reply**. Users replying to KM's own comment in a thread are ignored unless they re-mention KM in every turn — bad UX for multi-turn dialogue. + +Goal: KM should also auto-respond when a comment is a reply (direct or transitive) inside a thread that KM is already participating in. Plus, the LLM should see prior turns so replies feel like continued conversation. + +## Current behavior to extend + +- Polling driver: `src/poll-cli.ts`. Trigger gate is `findKmMentionInComment(comment, [kmAccountId, siteAccount])` at line ~197. +- Mention detection: `src/mentions.ts:findKmMentionInComment` — scans `block.annotations` for `Embed` link to KM/site, falls back to inline `@[…](hm://…)` regex. +- Thread context already gathered at reply time: `src/reply-engine.ts:gatherCommentReplyContext` walks `replyParent` chain up to 30 hops and includes the chain in the LLM prompt. So conversation memory is partly implemented — it's per-mention, not stored. +- Comment shape (`mentions.ts:SeedComment`): has `replyParent` and `threadRoot` fields populated by `seed-cli comment get`. +- Self-skip: `if (comment.author === kmAccountId) continue` (poll-cli.ts ~192) — must stay to avoid loops. +- Idempotency: `state.isProcessed(mid) || state.hasPlaceholderFor(mid)` — must stay. +- Invoker gate: currently bypassed by env var `KM_ENFORCE_INVOKER_GATE` (default off). Honor the toggle — when gate ON, the new auto-reply path must also pass the writer check. +- Per-day cap (`maxCommentsPerDay`, default 30) and blocked-list must apply to auto-replies too. + +## What to implement + +### 1. New trigger: comment is a thread reply to KM + +In `poll-cli.ts`, BEFORE the `findKmMentionInComment` check, add a second trigger path: + +- If `comment.replyParent` exists, fetch (or use cache) the parent comment. If parent's `author === kmAccountId`, treat as a triggering reply. +- Optionally also: if `comment.threadRoot` exists and any ancestor in the chain is authored by KM, trigger. Decision: start with direct-parent-only to keep the surface tight; add full-chain in a later pass if needed. +- Add a small in-process cache `kmReplyChainCache: Map` so transitive lookups don't re-fetch the same chain inside one poll cycle. +- When triggered without explicit mention, build the `Mention` via a new helper `buildThreadReplyMention(comment, ts)` that mirrors `buildCommentMention` but uses the full block text (since there's no Embed evidence to extract). Tag the `Mention` with a discriminator (e.g. `triggerSource: 'mention' | 'thread-reply'`) so audit logs can tell them apart. + +### 2. Audit event for the new path + +Emit `mention_via_thread_reply` info event with `{commentId, parentCommentId, docId, author}` so operator can grep for the new trigger. + +### 3. LLM context: include "you are KM, continuing a thread" + +`gatherCommentReplyContext` already walks the chain. Confirm it's used on this new path too (Pass B doesn't care how the mention was created). Update the system prompt fragment (look in `reply-engine.ts` for `draftReply` / DeepSeek prompt) to add: "If the user's comment is a reply to your earlier comment, treat it as a follow-up turn. Do not re-introduce yourself." + +### 4. Tests + +Add unit tests in `src/`: + +- `mentions.test.ts` or new `thread-reply.test.ts`: parent-of-KM detection given a `SeedComment` with `replyParent`. +- Mock `cli.runRead(['comment', 'get', ...])` to return a parent authored by KM and verify trigger fires. +- Negative case: parent authored by someone else → no trigger (unless explicit mention exists). + +Run `bun test src` and `bun run typecheck` after. Both must pass. + +### 5. Safety rails (do NOT touch) + +- Keep self-skip (`comment.author === kmAccountId`). +- Keep blocked-list (`blocked.has(mention.author)`). +- Keep idempotency check on `mentionKey`. +- Keep per-day cap. +- Honor `KM_ENFORCE_INVOKER_GATE` env var: if true, the thread-reply path also checks the writer set. + +## Deployment + +After typecheck + tests pass: + +```bash +cd seed-knowledge-manager/agent/mcp/seed-cli-mcp +bun run build +scp dist/poll-cli.js ubuntu@oc.hyper.media:/tmp/poll-cli.js +ssh ubuntu@oc.hyper.media 'sudo install -m 755 -o km -g km /tmp/poll-cli.js /home/km/km-agent/mcp/seed-cli-mcp/dist/poll-cli.js && sudo rm /tmp/poll-cli.js' +``` + +Timer `km-poll.timer` fires every 30s — next tick picks up new binary. + +## Verify deploy + +```bash +ssh ubuntu@oc.hyper.media 'sudo ls -t /home/km/km-logs/runs/ | head -3' +# Find newest run dir, then: +ssh ubuntu@oc.hyper.media 'sudo grep -E "mention_via_thread_reply|placeholder_posted|reply_finalised" /home/km/km-logs/runs//trace.jsonl' +``` + +End-to-end: comment on a doc mentioning KM, wait for reply, then reply to KM's reply WITHOUT @-mentioning. Expect new placeholder + final reply within one poll cycle. + +## Open questions to answer before coding + +1. Direct parent only, or full chain ancestor scan? (Recommend direct parent first.) +2. Should a reply in a thread KM started but where KM hasn't replied yet trigger? (Recommend NO — KM must have posted at least one comment in the chain.) +3. Cap auto-reply depth (e.g. KM only continues a thread for N turns) to avoid two KMs ping-ponging if a future variant deploys? +4. Persist conversation state across runs, or rely on on-demand chain walk every poll? (Chain walk works; persistence is optional.) + +## Critical files to read first + +- `src/poll-cli.ts` (Pass A trigger logic, lines 180–230) +- `src/mentions.ts` (Mention type, findKmMentionInComment, buildCommentMention) +- `src/reply-engine.ts` (gatherCommentReplyContext, draftReply prompt) +- `src/state.ts` (mentionKey, processed/placeholder idempotency) + +Read those before writing code. Then ask any of the open questions before implementing. diff --git a/.ai/seed-cli-reply-chain-fix.md b/.ai/seed-cli-reply-chain-fix.md new file mode 100644 index 000000000..e7b3edb46 --- /dev/null +++ b/.ai/seed-cli-reply-chain-fix.md @@ -0,0 +1,289 @@ +# Fix: `seed-cli comment create --reply` fails after comment edit + +## Bug summary + +`seed-cli comment create --reply ` fails with `"Non-base58btc character"` when the reply parent (or any ancestor in the chain) was previously edited via `seed-cli comment edit`. + +## Reproduction steps + +1. Post comment A on a document +2. Post comment B with `--reply A` -- works, threaded correctly +3. Edit comment B's body via `seed-cli comment edit B --body "new text"` -- creates new CID version +4. Post comment C with `--reply B` -- **fails** with `Non-base58btc character` +5. Posting C without `--reply` works but loses threading + +## Root cause analysis + +The bug is in the CLI's `comment create --reply` handler and in the `@seed-hypermedia/client` library's `createSignedComment` function. There are **two separate problems** in the data flow: + +### Problem 1: CLI passes RecordID where CID is expected (comment.ts lines 126-135) + +File: `frontend/apps/cli/src/commands/comment.ts` + +```typescript +if (options.reply) { + const parentComment = await client.request('Comment', options.reply) + const parentVersion = parentComment.version || parentComment.id + if (parentVersion) replyParent = parentVersion + if (parentComment.threadRoot) { + threadRoot = parentComment.threadRoot // <-- BUG: RecordID format + } else if (parentComment.version) { + threadRoot = parentComment.version + } +} +``` + +The `HMComment` type (from `hm-types.ts`) has: +- `threadRoot: string` -- a **RecordID** like `z6Mkvz9.../z6Gis...` (authority/tsid) +- `threadRootVersion: string` -- a **CID** like `bafyreig...` +- `replyParent: string` -- a **RecordID** +- `replyParentVersion: string` -- a **CID** + +The CLI uses `parentComment.threadRoot` (RecordID) as `rootReplyCommentVersion`, but the downstream code calls `CID.parse()` on it. RecordIDs contain a `/` separator which is not a valid base58btc character, causing the error. + +**For a first-level reply** (no threadRoot on the parent), the code falls to `threadRoot = parentComment.version` which IS a CID, so it works. That is why replies to unedited root comments succeed. + +**For deeper replies** (where the parent has a threadRoot), the code uses the RecordID format and `CID.parse()` fails. + +The edit operation does not change the RecordID or threadRoot of a comment -- it only creates a new version blob with the same TSID. So the real reason editing triggers the bug is likely that the KM agent's two-pass flow (post placeholder -> edit with final answer) creates a scenario where subsequent replies to the edited comment hit the **deeper reply path** (the parent now has threadRoot set because it was itself a reply). + +### Problem 2: CID.parse() in createSignedComment (comment.ts lines 306-307) + +File: `frontend/packages/client/src/comment.ts` + +```typescript +async function createSignedComment(comment: UnsignedComment, signer: HMSigner): Promise { + const commentForSigning = { + ...comment, + version: comment.version.split('.').map((v) => CID.parse(v)), + } as SignedComment + if (comment.threadRoot) commentForSigning.threadRoot = CID.parse(comment.threadRoot) + if (comment.replyParent) commentForSigning.replyParent = CID.parse(comment.replyParent) + // ... +} +``` + +`CID.parse()` is called on the `threadRoot` and `replyParent` strings. If these are RecordIDs instead of CID strings, the parse fails with the base58btc error. + +The same issue exists in `updateComment` (lines 495-496): +```typescript +if (input.replyParentVersion) comment.replyParent = CID.parse(input.replyParentVersion) +if (input.rootReplyCommentVersion) comment.threadRoot = CID.parse(input.rootReplyCommentVersion) +``` + +## How the server works (for reference) + +### Comment data model (Go) + +File: `backend/blob/blob_comment.go` + +```go +type Comment struct { + BaseBlob + ID TSID `refmt:"id,omitempty"` + Space_ core.Principal `refmt:"space,omitempty"` + Path string `refmt:"path,omitempty"` + Version []cid.Cid `refmt:"version,omitempty"` + ThreadRoot cid.Cid `refmt:"threadRoot,omitempty"` + ReplyParent_ cid.Cid `refmt:"replyParent,omitempty"` + Body []CommentBlock `refmt:"body"` + Visibility Visibility `refmt:"visibility,omitempty"` +} +``` + +### Comment proto response (Go) + +File: `backend/api/documents/v3alpha/comments.go`, function `commentToProto`: + +```go +pb := &documents.Comment{ + Id: blob.RecordID{Authority: cmt.Signer, TSID: tsid}.String(), // RecordID + Version: c.String(), // CID (base32 encoded) + // ... +} + +if cmt.ThreadRoot.Defined() { + ridRoot, _ := lookup.RecordID(cmt.ThreadRoot) + ridParent, _ := lookup.RecordID(cmt.ReplyParent()) + + pb.ThreadRoot = ridRoot.String() // RecordID format + pb.ThreadRootVersion = cmt.ThreadRoot.String() // CID format + pb.ReplyParent = ridParent.String() // RecordID format + pb.ReplyParentVersion = cmt.ReplyParent().String() // CID format +} +``` + +Key insight: The server returns BOTH formats -- RecordID (`threadRoot`, `replyParent`) and CID (`threadRootVersion`, `replyParentVersion`). The CLI must use the `*Version` fields (CID) for blob construction, not the RecordID fields. + +### CreateComment server handler (Go) + +File: `backend/api/documents/v3alpha/comments.go`, function `CreateComment`: + +```go +if in.ReplyParent != "" { + rpComment, err := srv.getComment(conn, in.ReplyParent) // Accepts RecordID or CID + replyParent = rpComment.CID // Uses the BLOB CID + threadRoot = rpComment.Comment.ThreadRoot // Uses the CBOR CID field + if !threadRoot.Defined() { + threadRoot = replyParent + } +} +``` + +The server's `getComment` resolves comments by RecordID (looking up by authority + TSID, returning the latest version). The server uses the internal CID from the blob, NOT the string IDs. + +### Comment edits and version chains + +When a comment is edited: +- A new blob is created with the SAME TSID but different CID +- The `qGetCommentByID` query returns the latest version (`ORDER BY sb.ts DESC LIMIT 1`) +- The `version` field in the response changes to the new blob's CID +- The `id` (RecordID) stays the same +- Threading fields (threadRoot, replyParent) stay the same (they reference the original blobs) + +## The fix + +### Fix 1: CLI `comment create` handler + +File: `frontend/apps/cli/src/commands/comment.ts` + +Change lines 123-135 from: + +```typescript +let replyParent: string | undefined +let threadRoot: string | undefined + +if (options.reply) { + const parentComment = await client.request('Comment', options.reply) + const parentVersion = parentComment.version || parentComment.id + if (parentVersion) replyParent = parentVersion + if (parentComment.threadRoot) { + threadRoot = parentComment.threadRoot + } else if (parentComment.version) { + threadRoot = parentComment.version + } +} +``` + +To: + +```typescript +let replyParent: string | undefined +let threadRoot: string | undefined + +if (options.reply) { + const parentComment = await client.request('Comment', options.reply) + // Use the CID version fields, not the RecordID fields. + // version = CID of the comment blob + // threadRootVersion = CID of the thread root blob (if this is a reply) + // replyParentVersion = CID of the reply parent blob (if this is a nested reply) + const parentVersion = parentComment.version || parentComment.id + if (parentVersion) replyParent = parentVersion + if (parentComment.threadRootVersion) { + threadRoot = parentComment.threadRootVersion // <-- Use CID, not RecordID + } else if (parentComment.version) { + threadRoot = parentComment.version + } +} +``` + +The key change: `parentComment.threadRoot` -> `parentComment.threadRootVersion` + +### Fix 2: Consider also fixing `replyParent` in the CLI `comment edit` handler + +File: `frontend/apps/cli/src/commands/comment.ts`, lines 198-213 + +The `edit` command already uses `existing.replyParentVersion` and `existing.threadRootVersion` correctly (lines 207-208). Verify this path is correct -- it appears to be. + +## Files to modify + +1. **`frontend/apps/cli/src/commands/comment.ts`** -- Primary fix: use `threadRootVersion` instead of `threadRoot` in the `create --reply` handler +2. **`frontend/packages/client/__tests__/comment.test.ts`** -- Add test for `createComment` with reply CID versions +3. **`frontend/apps/cli/src/test/cli.test.ts`** or **`frontend/apps/cli/src/test/cli-fixture.test.ts`** -- Add integration test for reply-after-edit scenario + +## Files to read (for context) + +All paths relative to the seed repo root (`/Users/horacioh/seed-hypermedia/seed`). + +| File | What to look at | +|------|-----------------| +| `frontend/apps/cli/src/commands/comment.ts` | CLI command handlers (create, edit, delete) | +| `frontend/packages/client/src/comment.ts` | `createComment`, `createSignedComment`, `updateComment`, `CID.parse()` calls | +| `frontend/packages/client/src/hm-types.ts` | `HMCommentSchema` -- the `threadRoot` vs `threadRootVersion` fields | +| `backend/api/documents/v3alpha/comments.go` | Server handler: `CreateComment`, `getComment`, `commentToProto` | +| `backend/blob/blob_comment.go` | `Comment` struct, `NewComment`, `ReplyParent()` fallback logic | +| `backend/blob/index.go` | `RecordID` type, `DecodeRecordID`, `LookupCache.RecordID` | +| `backend/blob/tsid.go` | `TSID` type, base58btc encoding | +| `backend/core/principal.go` | `Principal.String()` (base58btc encoding), `DecodePrincipal` | + +## Test plan + +### Unit test for the CLI fix + +Add to `frontend/packages/client/__tests__/comment.test.ts`: + +```typescript +it('creates a reply comment with threadRoot and replyParent CIDs', async () => { + const signer = makeSigner() + // These should be valid CID strings, not RecordIDs + const threadRootCID = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' + const replyParentCID = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' + + const publishInput = await createComment( + { + content: makeBlocks('reply text'), + docId: TEST_DOC_ID, + docVersion: threadRootCID, + blobs: [], + replyCommentVersion: replyParentCID, + rootReplyCommentVersion: threadRootCID, + }, + signer, + ) + + const decoded = cborDecode(publishInput.blobs[0]!.data) as any + expect(decoded.threadRoot).toBeDefined() + expect(decoded.replyParent).toBeUndefined() // Same as threadRoot, so omitted +}) +``` + +### Manual regression test + +1. Start a local seed daemon +2. Create a document +3. Post comment A on the document +4. Post comment B with `--reply A` +5. Edit comment B: `seed-cli comment edit B --body "edited text"` +6. Post comment C with `--reply B` -- should succeed (currently fails) +7. Verify comment C has correct `replyParent` and `threadRoot` +8. Post comment D with `--reply A` (non-edited chain) -- should still work + +## Impact on KM agent + +Once this fix lands in the seed repo, the KM agent workaround (skipping placeholders for thread-reply triggered comments) can be removed, restoring the two-pass UX (immediate "Working on this..." placeholder followed by the real answer). + +The workaround is in the seed-km repo at: +- `seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/poll-driver.ts` -- placeholder posting logic +- `seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/tools.ts` -- `seed_reply_comment` tool (line 325) + +## CID encoding note + +The Go `go-cid` library (v0.6.0) encodes CIDv1 as **base32lower** by default (strings starting with `b`). The JavaScript `multiformats` CID library handles multiple multibase encodings via `CID.parse()`, so base32 CIDs from the server parse correctly. The error only occurs when a non-CID string (RecordID with `/` separator) is passed to `CID.parse()`. + +## Run these commands after the fix + +```bash +# From the seed repo root: + +# TypeCheck +pnpm typecheck + +# Client package tests +pnpm --filter @seed-hypermedia/client test + +# CLI tests +pnpm --filter @shm/cli test + +# Full test suite +pnpm test +``` diff --git a/backend/config/config.go b/backend/config/config.go index e6b3a1fe6..4e3c02c9b 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -306,6 +306,10 @@ type Syncing struct { NoPull bool NoDiscovery bool AllowPush bool + // SubscriptionHotTier promotes subscription tasks into the scheduler's hot + // tier so capability/comment/ref blobs converge in ~hotTTL instead of one + // Interval. Costs more bandwidth; intended for headless agent daemons. + SubscriptionHotTier bool } func (c Syncing) Default() Syncing { @@ -328,6 +332,7 @@ func (c *Syncing) BindFlags(fs *flag.FlagSet) { fs.BoolVar(&c.AllowPush, "syncing.allow-push", c.AllowPush, "Allows direct content push. Anyone could force push content") fs.BoolVar(&c.NoPull, "syncing.no-pull", c.NoPull, "Disables periodic content pulling.") fs.BoolVar(&c.NoDiscovery, "syncing.no-discovery", c.NoDiscovery, "Disables the ability to discover content from other peers") + fs.BoolVar(&c.SubscriptionHotTier, "syncing.subscription-hot-tier", c.SubscriptionHotTier, "Keep subscription tasks in the hot scheduler tier so capability blobs converge in ~hotTTL") // Deprecated flags. Still defined here to avoid errors if these flags are passed. fs.Bool("syncing.smart", true, "Deprecated (doesn't do anything): Enables subscription-based syncing and deactivates dumb syncing") diff --git a/backend/hmnet/syncing/scheduler.go b/backend/hmnet/syncing/scheduler.go index a57ef6898..035ba925c 100644 --- a/backend/hmnet/syncing/scheduler.go +++ b/backend/hmnet/syncing/scheduler.go @@ -583,8 +583,19 @@ func (s *scheduler) scheduleNext(task *taskHandle, now time.Time, forceImmediate switch { case forceImmediate || task.runCount == 0: task.nextRunTime = now + if task.subscription && s.cfg.SubscriptionHotTier { + task.hotDeadline = now.Add(s.hotTTL) + } case task.subscription: task.nextRunTime = now.Add(s.cfg.Interval) + // When SubscriptionHotTier is enabled, keep the heartbeat alive past + // the next due time so dispatchReadyTasks' lazy migration promotes + // this task into the hot tier when it becomes due. Capability/comment + // blobs for subscribed sites then propagate ahead of cold ephemeral + // tasks instead of competing with them. + if s.cfg.SubscriptionHotTier { + task.hotDeadline = task.nextRunTime.Add(s.hotTTL) + } case task.IsHot(now): task.nextRunTime = now.Add(defaultHotCooldown) default: diff --git a/backend/storage/sqlite.go b/backend/storage/sqlite.go index 61651fb5d..4ef106201 100644 --- a/backend/storage/sqlite.go +++ b/backend/storage/sqlite.go @@ -57,6 +57,12 @@ func OpenSQLite(uri string, flags sqlite.OpenFlags, poolSize int) (*sqlitex.Pool "PRAGMA synchronous = NORMAL;", "PRAGMA journal_mode = WAL;", "PRAGMA cache_size = -262144;", + // Wait up to 5s when another writer holds the lock instead of + // returning SQLITE_BUSY immediately. Without this, headless + // agent daemons running on small VMs frequently lose reconcile + // transactions to peer-store/connect writes that grab the lock + // for a few ms at a time. + "PRAGMA busy_timeout = 5000;", "PRAGMA temp_store = MEMORY;", // Push the foreground auto-checkpoint threshold well above what the // background goroutine should ever let the WAL reach. See doc comment diff --git a/docs/research-lafh-knowledge-management.md b/docs/research-lafh-knowledge-management.md new file mode 100644 index 000000000..10a776f70 --- /dev/null +++ b/docs/research-lafh-knowledge-management.md @@ -0,0 +1,345 @@ +# El gestor de conocimiento según Luis Ángel Fernández Hermana +## Research deep-dive — para el skill de gestión de conocimiento en Seed Hypermedia + +> *"Las redes sociales de conocimiento son entornos virtuales altamente organizados en los que los participantes interactúan de acuerdo a una metodología implementada por un equipo de gestión de red, con el fin de alcanzar objetivos concretos mediante la creación de conocimiento nuevo."* +> — lab_RSI (Laboratorio de Redes Sociales de Innovación, dirigido por LAFH) + +--- + +## Parte I — Quién es LAFH y por qué importa + +Luis Ángel Fernández Hermana (Málaga, 1946) es periodista científico, profesor y consultor especializado en la conceptualización, diseño, desarrollo y gestión de **redes de conocimiento**. Fue corresponsal científico de El Periódico de Catalunya (1982–2004), corresponsal de la BBC, colaborador de Nature y TV3. + +En **enero de 1996** publicó el primer editorial de **en.red.ando**, una de las primeras revistas electrónicas en castellano dedicadas al impacto social, político, económico y cultural de Internet. Durante 8 años publicó más de 400 editoriales semanales (martes), recopilados después en los tres volúmenes de *Historia Viva de Internet* (Editorial UOC). + +En **1998** fundó **Enredando.com**, empresa pionera en el diseño y gestión de las primeras Redes Sociales Virtuales de Conocimiento (RSVC) en Internet. Cerró en julio de 2004. + +Posteriormente dirigió el **Laboratorio de Redes Sociales de Innovación (lab_RSI)** dentro de Citilab, donde produjo el cuerpo metodológico más maduro: la **Red Fractal** (2012), HipotecaGratis.com / Creditaria, Locomotora (Mataró), entre otras. + +Recibió el **Premi Ciutat de Barcelona** (2000), el **European Journalism Award** (2001), y fue elegido por El Mundo entre las 25 personas más influyentes en Internet en España (2000–2002). + +**Por qué importa para Seed Hypermedia:** LAFH no es un teórico de la "gestión del conocimiento" empresarial al estilo Nonaka/Takeuchi. Es alguien que **construyó plataformas reales** durante 25+ años para que comunidades distribuidas produjeran conocimiento nuevo, y desarrolló el conjunto de roles, métodos, métricas y advertencias que hacen que esto realmente funcione. Su obra es justamente lo que falta en la mayor parte de la literatura sobre "online communities". + +--- + +## Parte II — La distinción fundamental: GC vs GC-Red + +Esta distinción es el cimiento de todo lo demás y tiene que estar clara antes de diseñar el skill. + +### Gestión del Conocimiento (GC) — la tradición empresarial + +- Procede de Business Administration, años 60, EE.UU. +- Existe **independientemente de Internet** o de cualquier red. +- Se asienta sobre **una organización con organigrama nítido** — empleados, funciones delimitadas, objetivos estratégicos de la empresa. +- Mercado de miles de millones de dólares en EE.UU., dominado por grandes consultoras. +- En palabras de LAFH, el ámbito ha sido "embrollado" por la labor de las consultoras y la "larga sombra" de la gestión empresarial. + +### Gestión de Conocimiento en Red (GC-Red) — la propuesta de LAFH + +- Procede del mundo de **redes virtuales abiertas como Internet**. +- **No hay organización previa** — la crean los usuarios en función de sus intereses y los objetivos que fijan en cada caso. +- El vínculo entre los miembros **no tiene por qué existir, ni presuponerse** (no son colegas de oficina; pueden no conocerse). +- De aquí surgen foros, listas de distribución, comunidades virtuales, redes sociales, **redes sociales virtuales de conocimiento (RSVC)**. +- LAFH es muy explícito: **no hace** "gestión", ni "gestión de conocimiento", ni "gestión del conocimiento", ni "gestión del conocimiento en red". Hace **gestión de conocimiento en red** (sin "del"), y la diferencia no es semántica — es metodológica. + +**Implicación para Seed:** una comunidad en Seed encaja con GC-Red, no con GC. La gente se asocia por objetivos compartidos, no por organigrama. Esto cambia radicalmente el diseño del agente: no es un buscador corporativo, es un facilitador de producción de conocimiento entre desconocidos coordinados por intereses. + +--- + +## Parte III — Qué es una Red Social Virtual de Conocimiento (RSVC) + +> *"Espacio de encuentro virtual diseñado y gestionado con el fin de alcanzar objetivos concretos mediante el trabajo colaborativo en red. La dinámica está orientada a la recuperación y reelaboración de los intercambios que se producen entre sus miembros con el fin de obtener productos de conocimiento."* + +### Características clave + +1. **Tiene objetivos concretos.** Una RSVC no es un foro de cháchara; produce conocimiento aplicado a un proyecto. +2. **Construye conocimiento nuevo.** El propósito no es difundir lo que ya existe, sino generar lo que aún no existe. +3. **Trabaja sobre dos pilares:** + - **Estructura virtual** diseñada según pautas de un sistema de Generación y Gestión de Información y Conocimiento en red. + - **Equipo de gestión** preparado para aplicar la metodología de GC-Red. +4. **No reemplaza** al organigrama clásico de una organización — lo **superpone**, sacando a la luz "un mapa de conocimiento basado en formas de trabajo diferentes". + +### Productos típicos de una RSVC (qué entrega realmente) + +- Preparación y ejecución de proyectos +- Desarrollo de metodologías nuevas +- Materiales para líneas de negocio +- Contenidos pedagógicos (formal o informal) +- Trabajo cooperativo en áreas transversales +- Reorganización de territorios productivos +- Equipos preparados para emprender proyectos colaborativos +- **Información para la toma de decisiones** +- **Síntesis sistematizada de la actividad** (que es, en sí mismo, conocimiento aplicable a otras redes) + +### Ejemplo histórico — HipotecaGratis.com / Creditaria + +Caso pionero (2004). Una empresa hipotecaria con ~30 asesores convertida en RSVC. La pantalla de cada trabajador tenía dos mitades: a la izquierda, la base de datos de clientes; a la derecha, la red de conocimiento. Compartían en tiempo real las incidencias con clientes, qué funcionaba y qué fracasaba, dictámenes de expertos. + +**Resultados en 9 meses:** +- Ingresos por trabajador subieron entre **34% y 43%** (bonificaciones por contratos). +- Detectaron **antes que el mercado** las primeras señales del pinchazo de la burbuja inmobiliaria. Trasladaron la operación a México y fundaron Creditaria, que llegó a 85 oficinas conectadas. +- Cuando una persona dejaba la empresa, **los documentos sintéticos que recogían su forma de trabajar permanecían** y los nuevos los consultaban como si la persona aún estuviera. La memoria institucional sobrevivía a la rotación. + +Este caso es la mejor prueba de qué tiene que entregar el agente: **no resúmenes de conversaciones, sino documentos sintéticos que capturan formas de trabajar.** + +--- + +## Parte IV — El equipo de gestión (los cuatro perfiles) + +LAFH no concibe la gestión de una red de conocimiento como tarea de una sola persona. En su modelo maduro hay **cuatro roles**, que en redes pequeñas pueden recaer en la misma persona pero conviene distinguir conceptualmente porque atacan problemas diferentes. + +### 1. Gestor de la Red (Network Manager) + +- Diseña y organiza los **flujos de información** en la red. +- Trabaja siempre desde una **visión global de los objetivos**. +- Interviene en el diseño del "centro de operaciones": administración de perfiles, estructura de la red, normas de moderación, procesos de síntesis, evolución de la red. +- Es el más "arquitectónico" — pensar en él como el platform engineer. + +### 2. Moderador / Dinamizador + +Aquí está el grueso de la operación día a día. Sus funciones: + +- **Aplica la metodología de trabajo** dentro de la red. Esta es su función primera y más importante. +- Garantiza la **estabilidad de los intercambios** entre miembros. +- Aprueba/rechaza mensajes según las normas, elimina spam. +- **Está en contacto permanente con los participantes** — orienta sus formas de participar para elevar su capacidad de generar información en red. +- **Es el único miembro al tanto del flujo de información completo**, en tiempo real. Por eso puede regular el ritmo de producción para evitar el "**choque infosomático**" (el agobio que paraliza a la red). +- Establece pautas de comportamiento colectivo (respeto, documentación, contenido referenciado). +- **Trabaja en la zona de síntesis** — elabora boletines periódicos y documentos de conocimiento (temáticos o personales). +- **Promueve relaciones cruzadas** entre líneas temáticas que aparecen en debates o documentos aportados. +- Hilvana el debate mediante **recapitulaciones y resúmenes** para orientar y relanzar la discusión. +- Trabaja en corto, medio y largo plazo en función de los objetivos. + +### 3. Gestor de Conocimiento (Knowledge Manager) + +Este es el rol más cercano al que tu agente va a desempeñar: + +- **Crea y desarrolla el contexto** necesario para que los miembros produzcan información y conocimiento significativo. +- **Obtiene y procesa** documentos e informes relevantes. +- **Celebra entrevistas, solicita dictámenes a expertos**, elabora reseñas de eventos relacionados con la temática. +- **Investiga en Internet y en el mundo físico** — no se queda dentro de la red. +- **Establece relaciones con otras redes**, abre la posibilidad de alianzas con otros equipos, empresas o colectivos. +- Actúa desde la perspectiva del proyecto entero, no solo de lo que pasa dentro de la red. + +### 4. Responsable de Contenidos + +- Elabora contenidos nuevos publicables en la RSVC en relación con la temática. +- **Responde a demandas específicas** del moderador o de los gestores de conocimiento. +- Trabaja en colaboración con el equipo de redacción del medio de comunicación de la red. + +### Fusión de roles en redes pequeñas + +LAFH y otros autores que siguen su escuela (como el "Educacion y Aprendizaje Virtual" blog citando a Cáceres Tello) reconocen que en **"redes en fase inicial" o "experiencias iniciales"** las funciones del moderador y del gestor de conocimiento las realiza la misma persona, llegándose a denominar **"Moderador de redes"**. Esto es exactamente lo que tu agente necesita ser para una comunidad Seed pequeña/mediana. + +--- + +## Parte V — Las "zonas" de una red de conocimiento + +LAFH organiza la actividad de una RSVC en **zonas funcionales** distintas. Esto es importante porque el trabajo del agente no es uniforme: cambia según la zona en la que opera. + +### Zona de aportaciones + +Donde los miembros publican (mensajes, documentos, debates). Es donde el moderador "trabaja" según LAFH. La calidad del conocimiento depende aquí de que las aportaciones estén **documentadas y referenciadas** — esto es, según LAFH, "los cimientos básicos de la calidad de información aportada, de su credibilidad y fiabilidad". + +### Zona de síntesis + +Donde se transforman las aportaciones en productos de conocimiento (boletines, documentos temáticos, documentos personales). Es responsabilidad principal del moderador. Sin esta zona, una red genera ruido pero no acumula sabiduría. + +### Centro de operaciones + +Desde donde el gestor de la red administra perfiles, estructura, normas, métricas y la propia evolución de la red. + +### Implicación para el skill + +El agente debe operar **en las tres zonas** con métodos distintos: +- En la zona de aportaciones: orientar, sugerir referencias, regular ritmo. +- En la zona de síntesis: producir documentos sintéticos. +- En el centro de operaciones: monitorizar salud, detectar silos, reportar. + +--- + +## Parte VI — La metodología en acción: tareas concretas del gestor + +Reuniendo dispersos fragmentos de los textos de LAFH y lab_RSI, este es el conjunto de tareas operativas que el gestor desempeña. Las agrupo por familia funcional para usarlas después en el skill. + +### A. Captura, organización y clasificación + +- Registrar todo lo que produce la red (aportaciones, debates, documentos). +- Clasificar por temática, autor, fecha, relevancia. +- Mantener referencias explícitas (LAFH es categórico: contenido sin referenciar = contenido sin credibilidad). + +### B. Conexión y síntesis (el corazón del trabajo) + +- **Detectar relaciones cruzadas** entre líneas temáticas distintas. +- **Crear documentos de síntesis** — temáticos o personales — que consolidan conocimiento disperso. +- **Recapitular debates** mediante resúmenes que orienten y relancen la discusión. +- **Elaborar boletines periódicos** que sirvan como hito y como producto. +- Producir **borradores con valor de transferencia** (aplicables a otros proyectos, otras redes). + +### C. Curación y filtrado + +- Separar señal del ruido (qué aportación es valiosa, qué es efímera). +- Identificar contenido **desactualizado** o que **contradice** versiones más recientes. +- Mantener el conocimiento **vivo** — no permitir que quede enterrado bajo capas de actividad nueva. + +### D. Memoria institucional + +- Preservar el conocimiento cuando los miembros se van (caso HipotecaGratis). +- Responder con **historial y contexto correcto** — no solo con la última versión. +- Evitar la **reinvención de la rueda** — si algo ya se discutió, surfacearlo antes de que la comunidad lo rehaga. + +### E. Onboarding y mapeo de expertise + +- Ayudar a nuevos miembros a orientarse en el corpus existente. +- Responder "¿qué sabe esta comunidad sobre X?". +- **Redirigir preguntas al experto correcto** (mapa de expertise dinámico). + +### F. Detección de gaps + +- Identificar **qué no se sabe** y qué hay que investigar. +- Surfacear **preguntas sin respuesta**. +- Proponer documentos nuevos donde falta conocimiento estructurado. + +### G. Facilitación del discurso + +- Moderar y enriquecer conversaciones con contexto relevante. +- Capturar **acuerdos y desacuerdos clave** en debates. +- Fomentar la participación de miembros silenciosos. +- **Regular ritmo** — el "choque infosomático" es real y mata redes. + +### H. Monitoreo y salud de la red + +- Trackear actividad: qué se produce, quién contribuye, qué se ignora. +- **Detectar silos** — conocimiento que no circula entre subgrupos. +- Reportar el estado general de la base de conocimiento. +- Verificar la "adecuación de las normas de moderación". + +### I. Investigación externa y alianzas + +- No quedarse dentro de la red — investigar en Internet y en el mundo físico. +- Solicitar dictámenes a expertos externos. +- Establecer relaciones con otras redes; abrir posibilidad de alianzas. + +--- + +## Parte VII — Conceptos transversales clave (lenguaje LAFH) + +Estos términos aparecen en sus textos y conviene preservarlos en el skill — son distintivos y operacionales. + +| Término | Significado operativo | +|---|---| +| **GC-Red** | Gestión de conocimiento en red. La disciplina propia. | +| **RSVC** | Red Social Virtual de Conocimiento. La unidad básica. | +| **Zona de aportaciones** | Donde los miembros publican. | +| **Zona de síntesis** | Donde se elaboran productos de conocimiento. | +| **Choque infosomático** | Sobrecarga informativa que paraliza a los participantes. El moderador la previene regulando ritmo. | +| **Documento de síntesis** | Producto de conocimiento que consolida actividad dispersa de la red. Puede ser temático o personal. | +| **Boletín periódico** | Hito de síntesis publicado regularmente. | +| **Producto de conocimiento** | Outcome tangible (proyecto, método, decisión) que justifica la existencia de la red. | +| **Centro de operaciones** | Lugar arquitectónico desde donde se administra la red. | +| **Mapa de conocimiento** | Estructura emergente que se superpone al organigrama clásico. | +| **Normas de moderación** | Reglas consensuadas que ordenan el espacio. | +| **Información significativa y contrastada** | El estándar de calidad de las aportaciones. | +| **Documentación referenciada** | Cimiento de credibilidad y fiabilidad. | + +--- + +## Parte VIII — Advertencias de LAFH (qué *no* hacer) + +Tan importantes como las tareas son los anti-patterns que LAFH ha ido enunciando: + +1. **No confundir comunicar con generar conocimiento.** Una red puede tener mucha actividad y producir cero conocimiento nuevo. El éxito se mide en productos de síntesis, no en mensajes. + +2. **No confiar en que el conocimiento "se conserve solo" en la Red.** LAFH cita una estimación: cerca de 4/5 partes de la información y conocimiento generados en Internet desde su creación han desaparecido. Sin acto de síntesis, todo se pierde. + +3. **No imponer organigrama empresarial.** Las RSVC funcionan con vínculos voluntarios entre miembros con intereses compartidos; tratar de asignar tareas como en una empresa rompe la dinámica. + +4. **No saturar de información.** El moderador es el único que ve el flujo completo y debe regular el ritmo para evitar el choque infosomático. + +5. **No aceptar contenido sin referencias.** La credibilidad de la red depende del rigor documental. + +6. **No tratar al moderador como una "máquina de aprobar/rechazar".** Es el motor real de la generación de conocimiento — no una válvula de spam. + +7. **No olvidar la zona de síntesis.** Aportaciones sin síntesis = ruido acumulado. Hay que volver una y otra vez sobre lo aportado para producir conocimiento. + +8. **No reinventar la rueda.** Si la comunidad ya discutió algo, hay que surfacearlo antes de que se rehaga. + +--- + +## Parte IX — Aplicación al diseño del skill para Seed + +Síntesis: cómo el marco LAFH se traduce a un agente trabajando contra un sitio Seed. + +### Mapeo de roles + +- **Tu agente = Moderador de redes** en sentido fusionado (LAFH lo llama así para redes en fase inicial). Combina moderador + gestor de conocimiento. +- Para el **gestor de la red** (arquitectura, perfiles, métricas), el skill debe poder hacer **reportes** que tú o un humano accionen. +- Para el **responsable de contenidos**, el skill puede generar **borradores** que un humano publica. + +### Mapeo de zonas a Seed + +- **Zona de aportaciones** → documentos publicados, comentarios y bloques en Seed. +- **Zona de síntesis** → documentos nuevos creados por el agente con frontmatter `type: synthesis | digest | onboarding | gap-report`. +- **Centro de operaciones** → un documento "salud de la red" que el agente regenera periódicamente. + +### Capacidades del skill (modular, las pediste así) + +1. **Read & Answer** — responde a preguntas sobre el corpus con contexto histórico correcto. Detecta y enlaza menciones cruzadas. +2. **Active Curation** — produce documentos de síntesis (temáticos, personales, de debate), boletines periódicos, recapitulaciones. +3. **Gap Detection** — identifica preguntas sin respuesta, contradicciones, contenido desactualizado, silos. +4. **Onboarding** — para nuevos miembros, sintetiza "qué sabe esta comunidad sobre X" y redirige a expertos. +5. **Network Health** — reporte periódico de actividad, contribuciones, ignorados, silos. +6. **Pacing / Anti-overload** — al producir, regula volumen para no causar choque infosomático. + +### Lenguaje operativo del skill + +Conviene usar terminología LAFH explícita en los nombres de los outputs y plantillas: + +- `documento-de-síntesis` (no "summary") +- `boletín-periódico` (no "weekly digest") +- `mapa-de-expertise` +- `informe-de-salud-de-la-red` +- `pregunta-sin-respuesta` +- `relación-cruzada-detectada` + +Esto no es esnobismo — es preservar la coherencia conceptual del marco. Y es, además, lo que diferencia a tu skill de los 50 "knowledge base assistants" genéricos. + +--- + +## Parte X — Lecturas y fuentes + +### Obras de LAFH + +1. **En.red.ando** (Ediciones B, 1998, 489 pp.) — Recopilación de los primeros 100 editoriales semanales (1996–1997) más cuatro entrevistas. ISBN 978-84-406-8568-1. +2. **Historia Viva de Internet** (Editorial UOC, 2011 en adelante) — 3 volúmenes con los más de 400 editoriales de en.red.ando (1996–2004) más entrevistas. +3. **Editoriales semanales en en.red.ando** (1996–2004) — archivo público en coladepez.com, en castellano, catalán, gallego e inglés. + +### Recursos online clave + +- **lab-rsi.com** — Sitio del Laboratorio de Redes Sociales de Innovación. Especialmente las páginas: + - `/equipo-de-gestion-de-red-de-conocimiento/` — definición operativa de los cuatro roles. + - `/redes-de-conocimiento-2/` — qué es una RSVC. + - `/creacion-y-gestion-de-redes-sociales-virtuales-2/` — formación y consultoría. + - `/hipotecagratis-com/` — caso de éxito documentado. + - `/locomotora/` — caso histórico (Mataró). +- **coladepez.com** — Revista del propio LAFH. Especialmente: + - `/knowledge-network/gestion-de-conocimiento-en-red-gc-r-que-es-como-se-hace-con-que-instrumentos/` — la pieza fundacional sobre GC-Red. + - `/educationxxi/gestion-del-conocimiento-y-gestion-de-conocimiento-en-red-una-distincion-no-solo-metodologica/` — la distinción GC vs GC-Red. + - `/redes-de-conocimiento/` — índice temático. +- **lafh.info** — CV y publicaciones de LAFH. + +### Artículo académico relevante + +- **"Proyecto de la Red Fractal"**, LAFH, en *Desafío de las ciencias sociales en tiempos de transformación*, Universidad Pontificia Bolivariana — explicación detallada del último gran proyecto metodológico (2012). + +### Marco complementario (proyecto Accelera, UAB) + +Joaquín Gairín y David Rodríguez, en *La gestión del conocimiento en red* (Universidad Autónoma de Barcelona, 2005) y artículos posteriores, han desarrollado un modelo de competencias del gestor del conocimiento en entornos virtuales que dialoga directamente con el marco LAFH y formaliza algunas de sus categorías para el ámbito educativo. Útil como contraste académico. + +--- + +## Cierre + +LAFH te da algo que casi nadie más te da: una **teoría operacional** de cómo una comunidad distribuida produce conocimiento nuevo, basada en 25+ años de implementaciones reales. El agente que vas a construir no es un "chatbot que sabe del corpus"; es la **automatización parcial del rol de Moderador de Redes** en su sentido pleno, fusionado, ese que aplica metodología, modera, sintetiza, conecta, regula ritmo, mantiene memoria, detecta gaps y reporta salud. + +El skill que sigue está diseñado en ese espíritu. diff --git a/frontend/apps/cli/src/commands/site.ts b/frontend/apps/cli/src/commands/site.ts new file mode 100644 index 000000000..50cea3e13 --- /dev/null +++ b/frontend/apps/cli/src/commands/site.ts @@ -0,0 +1,165 @@ +/** + * Site commands — subscribe, unsubscribe, list-subscriptions, sync-status, + * reconcile (force-sync). Used to make a local daemon mirror a remote site. + */ + +import type {Command} from 'commander' +import {getClient, getOutputFormat, isPretty} from '../index' +import {formatOutput, printError, printSuccess} from '../output' +import {resolveIdWithClient} from '../utils/resolve-id' + +export function registerSiteCommands(program: Command) { + const site = program + .command('site') + .description('Manage site subscriptions on the local daemon (subscribe, sync-status, reconcile)') + + // ── subscribe ──────────────────────────────────────────────────────────── + + site + .command('subscribe ') + .description('Subscribe the local daemon to a site or document, mirroring its content') + .option('--recursive', 'Also subscribe to all documents in the directory', false) + .option('--wait', 'Wait for first sync to complete before returning (async=false)', false) + .action(async (id: string, options, cmd) => { + const globalOpts = cmd.optsWithGlobals() + const format = getOutputFormat(globalOpts) + const pretty = isPretty(globalOpts) + + try { + const {id: unpacked, client} = await resolveIdWithClient(id, globalOpts) + const path = (unpacked.path || []).filter(Boolean).join('/') + const result = await client.request('Subscribe', { + account: unpacked.uid, + path: path ? `/${path}` : '', + recursive: !!options.recursive, + async: !options.wait, + }) + if (globalOpts.quiet) { + printSuccess('subscribed') + } else { + console.log(formatOutput({status: 'subscribed', account: unpacked.uid, path: path ? `/${path}` : '', recursive: !!options.recursive, result}, format, pretty)) + } + } catch (error) { + printError((error as Error).message) + process.exit(1) + } + }) + + // ── unsubscribe ────────────────────────────────────────────────────────── + + site + .command('unsubscribe ') + .description('Unsubscribe the local daemon from a site or document') + .action(async (id: string, _options, cmd) => { + const globalOpts = cmd.optsWithGlobals() + + try { + const {id: unpacked, client} = await resolveIdWithClient(id, globalOpts) + const path = (unpacked.path || []).filter(Boolean).join('/') + await client.request('Unsubscribe', { + account: unpacked.uid, + path: path ? `/${path}` : '', + }) + if (!globalOpts.quiet) printSuccess('unsubscribed') + } catch (error) { + printError((error as Error).message) + process.exit(1) + } + }) + + // ── list-subscriptions ─────────────────────────────────────────────────── + + site + .command('list-subscriptions') + .description('List all active subscriptions on the local daemon') + .action(async (_options, cmd) => { + const globalOpts = cmd.optsWithGlobals() + const client = getClient(globalOpts) + const format = getOutputFormat(globalOpts) + const pretty = isPretty(globalOpts) + + try { + const result = await client.request('ListSubscriptions', {}) + if (globalOpts.quiet) { + for (const s of result.subscriptions) { + console.log(`hm://${s.account}${s.path}\t${s.recursive ? 'recursive' : 'single'}`) + } + } else { + console.log(formatOutput(result, format, pretty)) + } + } catch (error) { + printError((error as Error).message) + process.exit(1) + } + }) + + // ── sync-status ────────────────────────────────────────────────────────── + + site + .command('sync-status ') + .description('Report subscription state and writer-capability availability for a site') + .option('--writer ', 'Required writer account; ready_for_writes=true only when this account holds a WRITER capability locally') + .action(async (id: string, options, cmd) => { + const globalOpts = cmd.optsWithGlobals() + const format = getOutputFormat(globalOpts) + const pretty = isPretty(globalOpts) + + try { + const {id: unpacked, client} = await resolveIdWithClient(id, globalOpts) + const path = (unpacked.path || []).filter(Boolean).join('/') + const subPath = path ? `/${path}` : '' + + const subs = await client.request('ListSubscriptions', {}) + const matching = subs.subscriptions.find( + (s) => s.account === unpacked.uid && s.path === subPath, + ) + + const caps = await client.request('ListCapabilities', {targetId: unpacked}) + const writerCaps = caps.capabilities.filter((c) => { + const role = c.role || '' + return role.toUpperCase().includes('WRITER') + }) + + const readyForWrites = + !!matching && + (options.writer + ? writerCaps.some((c) => c.delegate === options.writer || c.account === options.writer) + : writerCaps.length > 0) + + const status = { + subscribed: !!matching, + recursive: matching?.recursive ?? false, + since: matching?.since, + writerCapCount: writerCaps.length, + ready_for_writes: readyForWrites, + } + + if (globalOpts.quiet) { + console.log(readyForWrites ? 'ready' : 'not-ready') + } else { + console.log(formatOutput(status, format, pretty)) + } + } catch (error) { + printError((error as Error).message) + process.exit(1) + } + }) + + // ── reconcile (force-sync) ─────────────────────────────────────────────── + + site + .command('reconcile') + .description('Force the daemon to run periodic background sync immediately (pulls capability/comment/ref blobs)') + .action(async (_options, cmd) => { + const globalOpts = cmd.optsWithGlobals() + + try { + const client = getClient(globalOpts) + await client.request('ForceSync', {}) + if (!globalOpts.quiet) printSuccess('sync triggered') + } catch (error) { + printError((error as Error).message) + process.exit(1) + } + }) +} diff --git a/frontend/apps/cli/src/index.ts b/frontend/apps/cli/src/index.ts index 98edd9fec..b7837af8a 100644 --- a/frontend/apps/cli/src/index.ts +++ b/frontend/apps/cli/src/index.ts @@ -19,6 +19,7 @@ import {registerSearchCommand} from './commands/search' import {registerQueryCommands} from './commands/query' import {registerKeyCommands} from './commands/key' import {registerDraftCommands} from './commands/draft' +import {registerSiteCommands} from './commands/site' import {getCliVersion} from './version' const program = new Command() @@ -104,6 +105,7 @@ registerContactCommands(program) registerAccountCommands(program) registerKeyCommands(program) registerDraftCommands(program) +registerSiteCommands(program) // Register top-level commands registerSearchCommand(program) diff --git a/frontend/packages/client/src/hm-types.ts b/frontend/packages/client/src/hm-types.ts index 83cd7ed69..a329eed84 100644 --- a/frontend/packages/client/src/hm-types.ts +++ b/frontend/packages/client/src/hm-types.ts @@ -1704,6 +1704,74 @@ export const HMPrepareDocumentChangeOutputSchema = z.object({ }) export type HMPrepareDocumentChangeOutput = z.infer +// Subscribe / Unsubscribe / ListSubscriptions / ForceSync schemas + +export const HMSubscribeInputSchema = z.object({ + account: z.string(), + path: z.string().default(''), + recursive: z.boolean().optional(), + async: z.boolean().optional(), +}) +export type HMSubscribeInput = z.infer +export const HMSubscribeOutputSchema = z.object({}) +export type HMSubscribeOutput = z.infer +export const HMSubscribeRequestSchema = z.object({ + key: z.literal('Subscribe'), + input: HMSubscribeInputSchema, + output: HMSubscribeOutputSchema, +}) +export type HMSubscribeRequest = z.infer + +export const HMUnsubscribeInputSchema = z.object({ + account: z.string(), + path: z.string().default(''), +}) +export type HMUnsubscribeInput = z.infer +export const HMUnsubscribeOutputSchema = z.object({}) +export type HMUnsubscribeOutput = z.infer +export const HMUnsubscribeRequestSchema = z.object({ + key: z.literal('Unsubscribe'), + input: HMUnsubscribeInputSchema, + output: HMUnsubscribeOutputSchema, +}) +export type HMUnsubscribeRequest = z.infer + +export const HMSubscriptionSchema = z.object({ + account: z.string(), + path: z.string(), + recursive: z.boolean(), + since: z.string().optional(), +}) +export type HMSubscription = z.infer + +export const HMListSubscriptionsInputSchema = z.object({ + pageSize: z.number().int().optional(), + pageToken: z.string().optional(), +}) +export type HMListSubscriptionsInput = z.infer +export const HMListSubscriptionsOutputSchema = z.object({ + subscriptions: z.array(HMSubscriptionSchema), + nextPageToken: z.string().optional(), +}) +export type HMListSubscriptionsOutput = z.infer +export const HMListSubscriptionsRequestSchema = z.object({ + key: z.literal('ListSubscriptions'), + input: HMListSubscriptionsInputSchema, + output: HMListSubscriptionsOutputSchema, +}) +export type HMListSubscriptionsRequest = z.infer + +export const HMForceSyncInputSchema = z.object({}) +export type HMForceSyncInput = z.infer +export const HMForceSyncOutputSchema = z.object({}) +export type HMForceSyncOutput = z.infer +export const HMForceSyncRequestSchema = z.object({ + key: z.literal('ForceSync'), + input: HMForceSyncInputSchema, + output: HMForceSyncOutputSchema, +}) +export type HMForceSyncRequest = z.infer + export const HMPrepareDocumentChangeRequestSchema = z.object({ key: z.literal('PrepareDocumentChange'), input: HMPrepareDocumentChangeInputSchema, @@ -1798,6 +1866,7 @@ export const HMGetRequestSchema = z.discriminatedUnion('key', [ HMListCommentVersionsRequestSchema, HMGetDomainRequestSchema, HMListDomainsRequestSchema, + HMListSubscriptionsRequestSchema, ]) export type HMGetRequest = z.infer @@ -1805,6 +1874,9 @@ export type HMGetRequest = z.infer export const HMActionSchema = z.discriminatedUnion('key', [ HMPublishBlobsRequestSchema, HMPrepareDocumentChangeRequestSchema, + HMSubscribeRequestSchema, + HMUnsubscribeRequestSchema, + HMForceSyncRequestSchema, ]) export type HMAction = z.infer @@ -1837,6 +1909,10 @@ export const HMRequestSchema = z.discriminatedUnion('key', [ HMListDomainsRequestSchema, HMPublishBlobsRequestSchema, HMPrepareDocumentChangeRequestSchema, + HMSubscribeRequestSchema, + HMUnsubscribeRequestSchema, + HMForceSyncRequestSchema, + HMListSubscriptionsRequestSchema, ]) export type HMRequest = z.infer diff --git a/frontend/packages/shared/src/api-force-sync.ts b/frontend/packages/shared/src/api-force-sync.ts new file mode 100644 index 000000000..1aeb333c4 --- /dev/null +++ b/frontend/packages/shared/src/api-force-sync.ts @@ -0,0 +1,40 @@ +import {HMRequestImplementation} from './api-types' +import {HMForceSyncRequest} from '@seed-hypermedia/client/hm-types' +import {discoveryUrl} from './discovery' +import {BIG_INT} from './constants' + +/** + * Trigger immediate discovery of every active subscription. + * + * The original `Daemon.ForceSync` RPC is deprecated and now returns + * `Unimplemented`. We replace it with a fan-out over the entities service: + * 1. List active subscriptions. + * 2. For each, call `Entities.DiscoverEntity` with `recursion=descendants` + * and `async=true` so the daemon promotes that subtree into the hot + * discovery tier without blocking the caller. + * + * Returns once all DiscoverEntity calls have been dispatched (not when + * discovery completes — that's monitored separately via `site sync-status`). + */ +export const ForceSync: HMRequestImplementation = { + async getData(grpcClient) { + const subs = await grpcClient.subscriptions.listSubscriptions({pageSize: BIG_INT}) + await Promise.all( + subs.subscriptions.map((s) => + grpcClient.entities.discoverEntity({ + id: discoveryUrl({ + uid: s.account, + path: pathStringToParts(s.path), + recursion: s.recursive ? 'descendants' : 'none', + }), + }), + ), + ) + return {} + }, +} + +function pathStringToParts(path: string): string[] | null { + if (!path) return null + return path.split('/').filter(Boolean) +} diff --git a/frontend/packages/shared/src/api-subscriptions.ts b/frontend/packages/shared/src/api-subscriptions.ts new file mode 100644 index 000000000..9854cedbb --- /dev/null +++ b/frontend/packages/shared/src/api-subscriptions.ts @@ -0,0 +1,45 @@ +import {HMRequestImplementation} from './api-types' +import {HMListSubscriptionsRequest, HMSubscribeRequest, HMUnsubscribeRequest} from '@seed-hypermedia/client/hm-types' + +/** Subscribe to a document or space (recursive=true mirrors all docs under path). */ +export const Subscribe: HMRequestImplementation = { + async getData(grpcClient, input) { + await grpcClient.subscriptions.subscribe({ + account: input.account, + path: input.path ?? '', + recursive: !!input.recursive, + async: input.async, + }) + return {} + }, +} + +/** Remove a subscription. */ +export const Unsubscribe: HMRequestImplementation = { + async getData(grpcClient, input) { + await grpcClient.subscriptions.unsubscribe({ + account: input.account, + path: input.path ?? '', + }) + return {} + }, +} + +/** List active subscriptions on this daemon. */ +export const ListSubscriptions: HMRequestImplementation = { + async getData(grpcClient, input) { + const result = await grpcClient.subscriptions.listSubscriptions({ + pageSize: input.pageSize, + pageToken: input.pageToken, + }) + return { + subscriptions: result.subscriptions.map((s) => ({ + account: s.account, + path: s.path, + recursive: s.recursive, + since: s.since ? s.since.toDate().toISOString() : undefined, + })), + nextPageToken: result.nextPageToken || undefined, + } + }, +} diff --git a/frontend/packages/shared/src/api.ts b/frontend/packages/shared/src/api.ts index 56cbfbada..ff62c69a0 100644 --- a/frontend/packages/shared/src/api.ts +++ b/frontend/packages/shared/src/api.ts @@ -23,7 +23,9 @@ import {ListCommentsByAuthor} from './api-list-comments-by-author' import {PrepareDocumentChange} from './api-prepare-document-change' import {PublishBlobs} from './api-publish-blobs' import {Query} from './api-query' +import {ForceSync} from './api-force-sync' import {QueryBlock} from './api-query-block' +import {ListSubscriptions, Subscribe, Unsubscribe} from './api-subscriptions' import {Resource, ResourceParams} from './api-resource' import {ResourceMetadata, ResourceMetadataParams} from './api-resource-metadata' import {Search} from './api-search' @@ -55,6 +57,7 @@ export const APIQueries = { ListCapabilities, ListDocumentCollaborators, InteractionSummary, + ListSubscriptions, } as const satisfies { [K in HMGetRequest['key']]: HMRequestImplementation> } @@ -62,6 +65,9 @@ export const APIQueries = { export const APIActions = { PublishBlobs, PrepareDocumentChange, + Subscribe, + Unsubscribe, + ForceSync, } as const satisfies { [K in HMAction['key']]: HMRequestImplementation> } diff --git a/seed-knowledge-manager/PROJECT.md b/seed-knowledge-manager/PROJECT.md new file mode 100644 index 000000000..5cd0c950b --- /dev/null +++ b/seed-knowledge-manager/PROJECT.md @@ -0,0 +1,286 @@ +# Seed Knowledge Manager + +*An autonomous moderator agent for Seed Hypermedia communities, grounded in 25+ years of network-knowledge-management practice.* + +--- + +## Problem + +Seed Hypermedia gives a community a place to publish documents, comment on them, and build a shared corpus. What it does not give the community is a way to turn that activity into **synthesised knowledge** — periodic bulletins, gap reports, expertise maps, network-health audits. Without that synthesis layer, communities exhibit what Luis Ángel Fernández Hermana (LAFH) called *choque infosomático* (an "info-somatic shock" — activity rises, knowledge production falls; members feel busy but the network forgets). The corpus accumulates and stays inert. + +In LAFH's terms — built across en.red.ando (1996), Enredando.com (1998), lab_RSI, and HipotecaGratis — a network only produces knowledge when it has a **synthesis zone** worked by a moderator-role. Synthesis is the bottleneck. No one has time. + +A second, narrower problem: Seed currently has no answer to "what does an autonomous agent inside a community look like?" If agents are coming, the substrate needs an opinion on how to host them, govern them, and bound their behaviour. + +## Solution + +A persistent agent — call it `@knowledge-manager` — that lives as a first-class member of a Seed community. Two equal-weight propositions: + +1. **Methodologically**: the first operational implementation of LAFH's *Gestión de Conocimiento en Red* (GC-Red, "knowledge management in networks") methodology on a modern hypermedia substrate. The agent does the synthesis work that the human moderator role calls for: weekly bulletin (*boletín periódico*), gap detection, network-health audits, grounded answers to community questions. + +2. **Architecturally**: the first agent governed *entirely by Seed documents*. Four community-editable docs — charter, rules, runbook, allowlist — define what the agent does, how it speaks, what it is allowed to write, and who can invoke it. No local YAML. No deploy to change behaviour. Edit the rules doc, save, the agent picks it up within 60 seconds. Proves the substrate can host autonomous participants on the same terms as human ones. + +Stack: Bun MCP server wrapping `seed-cli`, DeepSeek for language, XState v5 for per-mention lifecycle, Docker Compose for `seed-daemon` + `seed-web`, systemd timers for cadences — all on one Ubuntu host (`oc.hyper.media`). + +Status: **shipped, running in production on `oc.hyper.media`.** + +## Three demos + +### 1. Mention reply + multi-turn threads + +Write `@knowledge-manager what do we believe about ?` in a comment on any document. Within ~2 seconds a typing-indicator placeholder appears. Within ~30 seconds the placeholder is rewritten with a grounded answer citing existing docs via `hm://` links. If the question crosses prior debate, the answer flags agreement, disagreement, and open questions. + +**Multi-turn**: reply to KM's answer without re-mentioning — KM detects it's a thread it already participated in and continues the conversation. The LLM sees the full comment thread so follow-ups feel natural. No `@` needed after the first mention. + +### 2. Scheduled bulletin + +Monday at 09:00 UTC, a new bulletin appears at `hm://oc.hyper.media/agents/knowledge-manager/boletines/2026-W19`: new docs (prioritised, not exhaustive), active threads with status, decisions made, new members, gaps surfaced or filled, recommended reading. Two-minute scan. Wednesday a gap report drops. First of each month, a network-health audit drops. + +### 3. Telegram operator bot + +The operator DMs the bot: +- `/status` — current activity, last run, queue depth. +- `/last-runs` — last five run summaries with mention IDs. +- `/show-rules` — currently-parsed rules and cache age. +- `/poll-now` — force one poll cycle now. +- `/ask ` — freeform multi-turn query. + +Read-mostly by design; mutations live in governance docs, not in DMs. + +## Scope + +Three workstreams shipped on branch `knowledge-agent-server-setup`. + +### A. Agent runtime — `seed-knowledge-manager/` + +The agent itself: a Bun project at `agent/mcp/seed-cli-mcp/` wrapping `seed-cli` as MCP tools. + +- **Read tools**: `seed_search`, `seed_get_document`, `seed_get_comment_thread`, `seed_site_sync_status`, `seed_get_governance`. +- **Write tools** (gated by governance + rate limits): `seed_create_comment`, `seed_reply_comment`. Document writes via cadence driver only. +- **Three-pass polling driver** (`poll-cli.ts`): pass A discovers mentions and posts placeholder comments within ~2s; pass B drafts the real reply via DeepSeek and edits the placeholder in place; pass C handles thread-reply mentions (see below) with direct-reply posting. Stateless deduplication by mention ID. +- **Thread-reply trigger**: walks the `replyParent` chain (up to 30 hops, with cycle guard and per-cycle cache) to detect comments replying to a thread where KM already participated. Uses a pure helper (`detectThreadReplyToKm`) with injected fetcher for testability. Thread-replies skip the placeholder→edit flow and post the final answer directly (pass C) to work around a seed-cli `--reply` bug (see "Rabbit holes"). `Mention` type carries a `triggerSource: 'mention' | 'thread-reply'` discriminator for audit logs. +- **Cadence driver** (`cadence-cli.ts`): three LAFH outputs — `boletin` (weekly), `gap` (weekly), `health` (monthly). One DeepSeek call per task, deterministic output path. +- **Telegram operator bot** (`telegram-bot.ts`): long-running poller, allowlisted by Telegram user ID. +- **XState v5 lifecycle** (`machines/mention-machine.ts` + `supervisor.ts`): per-mention state machine, snapshotted to jsonl, replayable on crash. Behind feature flag `KM_USE_STATE_MACHINE`. +- **Bounded tool-call agent loop** (`agent/mastra-agent.ts`): ≤30 tool calls then forced `final_answer`. Lets the model dynamically expand context instead of running one deterministic prompt. Behind feature flag `KM_USE_MASTRA_AGENT`. +- **Governance loader** (`governance.ts`): fetches the four governance docs, parses the machine-readable YAML in `rules` and `allowlist`, caches 60 seconds. +- **Audit + redaction** (`audit.ts`, `redact.ts`): per-run directories with `meta.json` (summary), `trace.jsonl` (events), `llm.jsonl` (DeepSeek calls), `seed-cli.jsonl` (commands). Secrets redacted on disk. +- **Skill + templates**: `SKILL.md` documents the seven capabilities; `templates/{synthesis-document, boletin-periodico, gap-report, onboarding-capsule, network-health}.md` shape the outputs; `references/lafh-framework.md` carries the theoretical grounding. +- **Infrastructure**: Docker `compose.yaml` (`seed-daemon` + `seed-web`), systemd user units and timers (`km-poll`, `km-boletin`, `km-gap`, `km-health`, `km-reconcile`, `km-telegram`), idempotent install scripts (`install-phase1.sh`, `bootstrap-subscription.sh`), `secret-tool-shim` (file-backed keyring replacement), `km-log` (log browser). + +### B. `seed-cli site` commands — `frontend/apps/cli/src/commands/site.ts` + +New subcommands to manage subscriptions and force convergence from the CLI: + +- `seed-cli site subscribe [--recursive] [--wait]` +- `seed-cli site unsubscribe ` +- `seed-cli site list-subscriptions` +- `seed-cli site sync-status [--writer ]` — reports whether the local daemon has cached a given WRITER capability. +- `seed-cli site reconcile` — forces hot discovery via fan-out over `entities.discoverEntity`. + +Shared API helpers: `frontend/packages/shared/src/api-subscriptions.ts`, `frontend/packages/shared/src/api-force-sync.ts`. Used by the agent's preflight gate — the polling driver refuses to run unless `sync-status` confirms the writer capability is locally cached. + +### C. Backend hot-tier scheduler — `backend/hmnet/syncing/scheduler.go` + +Two-tier discovery queue: + +- `tierHot` (priority 0) preempts `tierCold` (priority 1). +- `hotDeadline` heartbeat TTL (~40s); expired hot tasks demote or drop. +- Hot tasks preempt running subscriptions and oldest in-flight hot tasks when workers saturate. +- New config flag `Syncing.SubscriptionHotTier` (`backend/config/config.go:312`): when on, subscription tasks ride the hot tier so writer-capability blobs converge in ~hotTTL instead of the next polling interval. +- `PRAGMA busy_timeout = 5000` in `backend/storage/sqlite.go:33` — wait 5 seconds on a writer lock instead of returning `SQLITE_BUSY` immediately. Protects reconcile transactions from being starved by peer-store writes on small VMs. + +Without this, an agent that subscribes to a community sees the WRITER capability only after a multi-minute polling sweep — long enough that "subscribe then run" doesn't work in practice. With it, `subscribe --wait` converges in seconds. + +## How it works — Architecture + +Single Ubuntu 24.04 host. User `km` with linger + docker group. + +``` + ┌───────────────────────┐ + :55000 P2P → │ seed-daemon │ ← Docker + :55001 HTTP │ (seedhypermedia/ │ + :55002 gRPC │ site:latest) │ + └─────────┬─────────────┘ + │ seed-cli + ▼ + ┌──────────────────────────┐ + │ seed-cli-mcp (Bun) │ ─── DeepSeek API + │ + governance cache │ + │ + audit │ + └──┬────────┬──────────┬───┘ + │ │ │ + invoked by each timer / service + │ │ │ + ▼ ▼ ▼ + km-poll km-{boletin, km-telegram + (15-30s) gap,health} (long-running) + │ + ▼ + posts comments / docs to + oc.hyper.media (seed-web :3000) +``` + +**Governance flow.** On every action point the agent reads the four docs at `hm://oc.hyper.media/agents/knowledge-manager/{charter,rules,runbook,allowlist}` (cache TTL 60s). The `rules` doc carries a YAML block with caps (`max_docs_per_run`, `max_comments_per_run`, `max_comments_per_day`), mention triggers, invoker source (WRITER capability or allowlist doc), and a `draft_only` kill switch. Toggling `draft_only: true` stops document writes within 60 seconds; comments continue. + +**Hardcoded denylist.** Regardless of permissions, `limits.ts` refuses to write to the four governance paths. Operators can edit governance; the agent cannot. + +**Feature flags** (default off, ready to ship on): +- `KM_USE_LOCAL_DAEMON` — talk to the local daemon instead of a public gateway. Required for self-contained operation. +- `KM_USE_STATE_MACHINE` — XState lifecycle with snapshot/replay across crashes. +- `KM_USE_MASTRA_AGENT` — bounded tool-call loop instead of single-shot prompt. + +## How to interact + +### As a community member +- Mention `@knowledge-manager` in any comment to get a grounded answer with `hm://` citations. +- Reply to KM's answers directly — no need to re-mention. KM continues the conversation as a follow-up turn. +- Read the auto-published cadence docs under `hm://oc.hyper.media/agents/knowledge-manager/`. + +### As an operator (write access to governance docs) +- Edit `…/rules` to change caps, set `draft_only: true`, or restrict invokers. +- Edit `…/runbook` to change tone, citation style, or escalation policy. +- Edit `…/allowlist` to whitelist mentioners (when `invoker_source: allowlist-doc`; default is WRITER capability). +- Edit `…/charter` to redefine scope. + +### As a Telegram operator (allowlisted by Telegram user ID) +- `/status`, `/last-runs`, `/show-rules`, `/poll-now`, `/ask `. + +### As an SRE on the host + +```bash +sudo -u km bash -lc '/home/km/.local/bin/km-log tail' +sudo -u km bash -lc '/home/km/.local/bin/km-log latest 5' +sudo -u km bash -lc '/home/km/.local/bin/km-log show ' +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user start km-poll.service +``` + +## State machines + +Behind feature flag `KM_USE_STATE_MACHINE`. One XState v5 machine definition with a supervisor layer for persistence and crash recovery. + +### Mention machine (`machines/mention-machine.ts`) + +Per-mention lifecycle. Each incoming mention spawns one actor. Side effects (placeholder posting, LLM drafting, comment editing) are injected via `fromPromise` actors — the machine itself is pure. + +``` + ┌─────────────────┐ + │ detected │ (initial) + └──┬──────┬───────┘ + ENQUEUE │ │ NOT_ALLOWED / CAP_DENIED + ▼ ▼ + ┌──────────┐ ┌──────────────────┐ + │ enqueued │ │ skipped_not_ │ (final) + └────┬─────┘ │ allowed / │ + POST_ │ │ cap_exceeded │ + PLACEHOLDER │ └──────────────────┘ + ▼ + ┌───────────────────┐ + │ placeholder_ │ + │ pending │ + └──┬────────────┬───┘ + PLACEHOLDER_ │ │ PLACEHOLDER_FAILED + POSTED ▼ ▼ + ┌────────────────┐ ┌─────────────────┐ + │ placeholder_ │ │ failed_terminal │ (final) + │ posted │ └─────────────────┘ + └──────┬─────────┘ + RUN_ │ + AGENT ▼ + ┌────────────────┐ + │ agent_running │◄──────────────────┐ + └──┬─────────┬───┘ │ + AGENT_ │ │ AGENT_ERROR │ + DONE │ ▼ │ + │ [canRetryDraft?] │ + │ yes → agent_backoff ─────────┘ + │ no → failed_terminal (final) + ▼ + ┌────────────────┐ + │ draft_ready │ + └──────┬─────────┘ + FINALISE │ + ▼ + ┌────────────────┐ + │ finalising │◄──────────────────┐ + └──┬─────────┬───┘ │ + FINALISED │ │ FINALISE_ERROR │ + │ ▼ │ + │ [canRetryFinalise?] │ + │ yes → finalise_backoff ──────┘ + │ no → failed_terminal (final) + ▼ + ┌────────────────┐ + │ done │ (final) + └────────────────┘ +``` + +**9 states**: `detected`, `enqueued`, `placeholder_pending`, `placeholder_posted`, `agent_running`, `agent_backoff`, `draft_ready`, `finalising`, `finalise_backoff`. + +**4 terminal states**: `done`, `skipped_not_allowed`, `cap_exceeded`, `failed_terminal`. + +**12 events**: `ENQUEUE`, `CAP_DENIED{reason}`, `NOT_ALLOWED{reason}`, `POST_PLACEHOLDER`, `PLACEHOLDER_POSTED{placeholderId}`, `PLACEHOLDER_FAILED{reason}`, `RUN_AGENT`, `AGENT_DONE{replyBody}`, `AGENT_ERROR{reason}`, `FINALISE`, `FINALISED`, `FINALISE_ERROR{reason}`. + +**2 guards**: `canRetryDraft` (`draftRetries < 3`), `canRetryFinalise` (`finaliseRetries < 3`). + +**2 delays** (exponential backoff): `draftBackoff` = `2000ms × 2^retries`, `finaliseBackoff` = `2000ms × 2^retries`. + +**Context** carried per mention: +| Field | Type | Set by | +|---|---|---| +| `mention` | `Mention` | spawn | +| `placeholderId` | `string \| null` | `PLACEHOLDER_POSTED` | +| `replyBody` | `string \| null` | `AGENT_DONE` | +| `failureReason` | `string \| null` | terminal events | +| `draftRetries` | `number` | `AGENT_ERROR` (increment) | +| `finaliseRetries` | `number` | `FINALISE_ERROR` (increment) | +| `lastError` | `string \| null` | transient failures | + +### Supervisor (`machines/supervisor.ts`) + +Orchestrates one actor per mention. Responsibilities: + +- **Spawn**: creates and starts a fresh actor for each mention. +- **Persist**: appends every transition to `${stateDir}/machines/.jsonl`. Each line is `{ts, type, payload?, initialMention?}`. +- **Rehydrate**: on startup, replays all JSONL event logs. Terminal actors are dropped; in-flight actors are restored to their last state. +- **Stop**: graceful shutdown of all actors. + +### Poll driver (`machines/poll-driver.ts`) + +Bridges `poll-cli.ts` Pass B with the supervisor. Called when `config.useStateMachine` is enabled: + +1. Creates `MentionSupervisor` with side-effect callbacks. +2. Rehydrates from prior runs (crash recovery). +3. For each pending placeholder: spawn actor → feed bootstrap events (`POST_PLACEHOLDER` → `PLACEHOLDER_POSTED` → `RUN_AGENT`) → execute LLM → send `AGENT_DONE`/`AGENT_ERROR` → attempt finalization → send `FINALISED`/`FINALISE_ERROR`. +4. Stops all actors. + +Note: the state machine path currently handles **placeholder-based mentions only** (Pass B). Thread-reply mentions (Pass C, direct-reply) bypass the machine and run a single-shot draft→post flow. Once seed-cli `--reply` is fixed upstream, thread-replies can rejoin the machine-driven path. + +## Rabbit holes (we went there) + +- **Nanobot gateway.** Tried nanobot as the MCP gateway for free-form agent orchestration. It does not bundle cleanly with Bun, and the surface area we needed was small enough to re-implement inline. Result: kept `nanobot-gateway.service` as an optional off-path surface; built our own bounded tool-call loop in `agent/mastra-agent.ts`. +- **Cursor-based activity walker.** First pass scanned the activity feed with a persistent cursor to detect mentions. Race conditions on cursor advancement caused dropped mentions and double replies under load. Replaced with stateless per-poll deduplication keyed by mention ID (commit `0fcbb02cc`). +- **seed-cli `--reply` and edited comments.** `seed-cli comment create --reply ` uses `parentComment.threadRoot` (a RecordID containing `/`) instead of `parentComment.threadRootVersion` (a CID). `CID.parse()` chokes on the `/` with `"Non-base58btc character"`. Fails for any parent that is itself a threaded reply (has `threadRoot` set). The placeholder→edit flow makes it worse: editing a placeholder inserts an edited comment into the chain, breaking all subsequent `--reply` calls in that thread. Workaround: thread-reply mentions skip the placeholder phase and post the final answer directly (pass C). Upstream fix is a one-liner in `frontend/apps/cli/src/commands/comment.ts` line 131 — tracked in `.ai/seed-cli-reply-chain-fix.md`. +- **Gnome-keyring on a headless server.** `seed-cli` defaults to libsecret/Gnome-keyring for key storage; a headless Ubuntu host has no session bus to run `gnome-keyring-daemon`. Wrote `secret-tool-shim`: file-backed JSON at `~/.config/seed-keyring/secrets.json`, mode 600, drop-in compatible with the `secret-tool` CLI invocations `seed-cli` makes. + +## No-gos (explicit boundaries) + +- **Agent edits its own governance.** Hardcoded denylist in `limits.ts` prevents writes to the four governance paths regardless of permissions. +- **Destructive operations.** Agent has zero delete / unlink / move capability. Only creates comments and documents. +- **Mass DMs or outbound messaging.** No invite, no DM, no email. All output lives inside the community as comments or documents on the site. +- **Self-promotion / re-posting.** Caps of 1 doc per run, 5 comments per run, 30 comments per day, enforced *before* every write. +- **Mocked tests on the integration path.** Integration tests hit a real local `seed-daemon`; mocks only inside unit boundaries. +- **Cross-workspace imports that break the repo's dependency graph.** Vault stays Bun, repo stays pnpm, agent MCP stays Bun under `seed-knowledge-manager/`. No reach-across imports. + +## Next steps + +- **Operator dashboard.** Move beyond Telegram and logs. A small web UI on the existing `seed-web` container showing per-run audit traces, governance cache state, mention queue, cadence run history. +- **Multi-tenant.** Today one agent process serves one site. Generalise to one agent serving N sites via per-site governance docs, per-site state dirs, and a small site-registry doc. Will require factoring `config.ts` into a per-site loader. +- **Fix seed-cli `--reply` upstream.** One-line fix: `parentComment.threadRoot` → `parentComment.threadRootVersion` in `frontend/apps/cli/src/commands/comment.ts:131`. Once merged, thread-replies can rejoin the placeholder→edit flow for instant "Working on this..." feedback. Prompt at `.ai/seed-cli-reply-chain-fix.md`. +- **Remove the `km-reconcile.timer` band-aid.** Once `SubscriptionHotTier=true` ships on by default, the 60-second reconcile timer becomes redundant. +- **Wire the remaining capabilities.** Templates exist for synthesis docs (capability #2), expertise maps (#5), and cross-reference detection (#6) — no cadence driver yet. Likely mention-triggered, not scheduled. +- **Promote the bounded tool-call agent.** Default `KM_USE_MASTRA_AGENT=1` once we have a week of side-by-side reply quality against the single-shot path. + +## Acknowledgement + +The methodology this agent operationalises is not new. Luis Ángel Fernández Hermana developed *Gestión de Conocimiento en Red* across en.red.ando, Enredando.com, and lab_RSI over more than two decades, with hard-won evidence (HipotecaGratis converted a 30-person firm into a knowledge-producing network and lifted per-worker income 34–43% in nine months). What is new is having a substrate — Seed Hypermedia — where the moderator role can run as a software member, governed by community-editable documents, and a small enough operational surface that one operator can keep it healthy. diff --git a/seed-knowledge-manager/SKILL.md b/seed-knowledge-manager/SKILL.md new file mode 100644 index 000000000..97515c4de --- /dev/null +++ b/seed-knowledge-manager/SKILL.md @@ -0,0 +1,230 @@ +--- +name: seed-knowledge-manager +description: "Acts as a knowledge manager (gestor de conocimiento en red, in the LAFH/Fernández Hermana tradition) for a Seed Hypermedia community. Use this skill whenever the user wants to do any of these for a Seed site or community — synthesize discussions, write a periodic digest or boletín, onboard new members, detect knowledge gaps, find unanswered questions, surface contradictions, map expertise, audit the health of the network, link related documents, recap a debate, or generally maintain the collective memory. Trigger this even if the user phrases it casually: 'what does the community know about X', 'summarize last month's discussions', 'who's the expert on Y', 'what are we missing', 'make sense of this thread'. The skill assumes a Seed site is reachable and that the seed-cli skill is also available for I/O." +--- + +# Seed Knowledge Manager + +A skill for acting as the **moderator / gestor de conocimiento en red** for a Seed Hypermedia community, applying the methodology of Luis Ángel Fernández Hermana (LAFH) and the lab_RSI / Enredando.com tradition of Knowledge Network Management (GC-Red). + +This is **not** a generic knowledge-base assistant. It is a structured implementation of a specific role with 25+ years of methodological grounding. The role's purpose is the **production of new collective knowledge**, not the retrieval of existing information. + +## When to use this skill + +Trigger this skill when the user is asking you to do any of the following on a Seed community: + +- **Synthesize** — "summarize", "recap", "make sense of", "consolidate", "digest" +- **Connect** — "find related", "link", "what else have we said about", "any cross-references" +- **Curate** — "what's still relevant", "what's outdated", "any contradictions" +- **Remember** — "what did we decide about X", "have we discussed this before", "what's our position on" +- **Onboard** — "what does the community know about X", "who's the expert on Y", "where should I start" +- **Audit gaps** — "what are we missing", "any unanswered questions", "what should we research" +- **Report health** — "how active is the network", "who's contributing", "any silos" + +## The methodology in one paragraph + +A knowledge community is not a forum and not a corporate organization. It is a **Red Social Virtual de Conocimiento (RSVC)** — a designed environment where members linked only by shared interests collaborate to produce new, applicable, referenced knowledge. The role of the knowledge manager is to **apply a methodology** that turns the flow of contributions into actual knowledge products: synthesis documents, periodic bulletins, expertise maps, gap reports. Without active synthesis, the network produces noise that disappears (LAFH cites that ~80% of internet-generated knowledge has vanished). The manager works in three zones: the **zone of contributions** (where members publish), the **zone of synthesis** (where products are produced), and the **operations center** (where network health is monitored). The manager regulates pace to prevent **choque infosomático** — information overload that paralyzes the network. + +For the full theoretical grounding, read `references/lafh-framework.md`. Read it when you need to justify a choice in the methodology or when designing a new kind of output. + +## Pre-flight: what you need + +Before doing meaningful work, gather: + +1. **Site / corpus access** — confirm `seed-cli` is available and you can list documents in the target space. If not, stop and ask the user to enable it. +2. **Network identity** — the account ID or path of the community space (e.g. `hm://abc123/community`). +3. **Network purpose / objectives** — ask the user briefly what the community is *for* if it's not obvious from the homepage. Without a sense of purpose, you can't separate signal from noise. One sentence is enough. +4. **Member map (if available)** — try to identify the active contributors. If Seed exposes this, use it; otherwise infer from authorship across recent docs. + +If any of these are missing and you can't infer them, **ask one question** before proceeding. Don't bury the user in a questionnaire. + +## The capabilities + +This skill is modular. The user invokes one capability at a time. Pick the matching one based on the request. + +### 1. Read-and-answer (the daily mode) + +When the user asks a question that the community's corpus might already have addressed. + +**Process:** +1. Search the corpus for relevant documents (use `seed-cli` query/search). +2. Read enough to give an honest answer with **historical context**, not just the latest version. +3. **Always** mention if the topic has been discussed before, and **link** to the prior documents. +4. If you find contradictions between older and newer takes, **flag the contradiction** — don't paper over it. +5. If the question has no good answer in the corpus, say so and recommend creating a "pregunta-sin-respuesta" entry (see capability 4). + +**LAFH principle behind it:** avoid letting the community "reinvent the wheel". + +### 2. Synthesis document creation (the core production) + +When the user asks for a summary, recap, consolidation, or "make sense of X". + +**You are producing a real knowledge product, not chat output.** Use the `templates/synthesis-document.md` template. The output must: + +- Have a clear purpose stated at the top (what question or thread does this consolidate?) +- Cite every source with a `hm://` link to the specific block where possible +- Distinguish between **areas of agreement**, **areas of disagreement**, and **open questions** +- End with a "next steps" or "what's missing" section +- Include `type: synthesis` in the frontmatter so the document is queryable later + +If the user asks for a one-paragraph summary, give them that inline. But for anything beyond that, **propose creating a real synthesis document in Seed** and only do so after they confirm. Don't dump 2000 words inline. + +See `templates/synthesis-document.md` for the structure. + +### 3. Periodic bulletin (boletín) + +When the user asks for a weekly/monthly digest, "what's been happening", or recap of a time period. + +Use `templates/boletin-periodico.md`. The boletín differs from a synthesis document in that it is **temporal** rather than thematic. It rolls up: + +- New documents published in the period (with one-line takeaways) +- Active threads (with current state — agreement reached? blocked? open?) +- Decisions made +- New members and what they've contributed +- Gaps surfaced or filled +- Recommended reading for the period + +Keep it scannable. Length matters less than structure. + +### 4. Gap detection (preguntas sin respuesta) + +When the user asks "what are we missing" or "what should we research". + +**Process:** +1. Scan recent threads for questions that received no resolution. +2. Scan synthesis documents for "open questions" sections. +3. Look for topics that come up repeatedly but have no consolidating document. +4. Look for topics where the community has fragmented opinions but no decision document. +5. Output: a list of gaps, each with evidence (links) and a proposed action (research, discuss, decide, document). + +Use `templates/gap-report.md`. Don't produce a vague list — every gap needs evidence and a proposed action. + +### 5. Expertise map / onboarding + +When a new member arrives, or someone asks "what does the community know about X" or "who knows about Y". + +**Process:** +1. Identify the topic. +2. Find the foundational documents on that topic in the corpus (the ones most cited or most recent canonical synthesis). +3. Identify the most active contributors on that topic by recent authorship and commenting. +4. Output: an onboarding capsule that includes: + - "What this community has decided / believes about X" + - "Open questions on X" + - "People to talk to about X" + - "Documents to read in order" + +Use `templates/onboarding-capsule.md`. Keep it short — too much breaks the welcome effect. + +### 6. Cross-reference detection + +When the user asks to find related content, or when you're producing a synthesis document and want to enrich it. + +**Process:** +1. From a starting document or thread, identify the key concepts/entities. +2. Search the corpus for other documents that mention the same concepts. +3. Distinguish: documents that **agree**, documents that **disagree**, documents that **extend**, documents that **contradict**. +4. Propose adding explicit links (in Seed: `[title](hm://...#blockId)`) at appropriate points in the source document. + +Don't just list related docs — **classify the relationship**. That's what turns a list into a graph. + +### 7. Network health report + +When the user asks how the community is doing, or periodically (suggest monthly). + +Use `templates/network-health.md`. Report on: + +- **Activity** — number of new docs, comments, active members +- **Production** — has the network produced any new knowledge product (synthesis, decision, method) in the period? If not, this is a red flag per LAFH. +- **Silos** — are there subgroups whose docs don't reference each other? List them. +- **Stale corpus** — documents that haven't been touched and are likely outdated. +- **Pace** — is the network in choque infosomático (too much, too fast, no synthesis)? Or stagnant? +- **Memory** — are recent decisions backed by referenced documents, or floating in chat? + +Be diagnostic, not flattering. The user wants real signal. + +## How to do all of this on Seed (I/O contract) + +This skill produces **markdown documents with YAML frontmatter** and **comments** — both of which Seed handles natively. The skill itself does not call Seed APIs directly; it generates content and tells the user (or a calling agent) which `seed-cli` operations to run. + +### Frontmatter conventions + +Use these `type` values consistently so the corpus becomes self-organizing: + +- `type: synthesis` — a synthesis document (capability 2) +- `type: boletin` — a periodic bulletin (capability 3) +- `type: gap-report` — gap detection output (capability 4) +- `type: onboarding` — onboarding capsule (capability 5) +- `type: network-health` — health report (capability 7) +- `type: decision` — when a community decision is captured (use this when surfacing past decisions) + +Always include: + +```yaml +--- +title: +type: +period: +covers: +sources: +created_by: knowledge-manager +created_at: +--- +``` + +### Linking style + +Always use full `hm://` links with block fragments where possible: `[Title](hm://account/path#blockId)`. This is non-negotiable per LAFH's rule on referenced documentation. + +### Comments vs. new documents + +Use the right surface: + +- **Inline comment on a block** — when flagging a contradiction or suggesting an edit on an existing doc +- **Threaded reply** — when participating in an active discussion to surface context +- **New document** — for any synthesis, gap report, bulletin, or onboarding capsule + +Never create a new document for what could be a comment; never bury synthesis in comments. + +### Pacing rule (anti-choque-infosomático) + +Do not flood the community. When producing outputs: + +- **Per session**: at most one synthesis document, one bulletin, or one health report. Multiple is fine if explicitly asked. +- **Don't auto-publish**. Always show the user the draft and confirm before any write. +- **For bulletins**: cap items per section at ~5–7. If there are more candidates, *prioritize* — don't list everything. + +## Voice and tone + +Match the community's voice (read recent docs first if you don't know it). Default characteristics: + +- **Concise**. Synthesis documents are tighter than the threads they summarize. +- **Referenced**. Every claim links back. No floating assertions. +- **Honest about uncertainty**. If the corpus is contradictory, say so. If something is your inference, mark it. +- **Non-promotional**. The skill is invisible infrastructure. No "I have prepared for you a comprehensive..." +- **In the language of the community**. If the community works in Spanish, output in Spanish. If English, English. If mixed, follow the source thread. + +## Output format expectations + +- For inline answers in chat: **prose**, no headers, brief. +- For documents to be created in Seed: use the templates and frontmatter above. +- For lists of items (gaps, related docs, members): structured but tight — one line per item with an inline link. + +## What this skill does NOT do + +- It does not act as a customer support bot. +- It does not produce marketing or promotional content for the community. +- It does not enforce moderation rules (spam removal, banning) — that's a different role (the moderator's spam handling) and should be a human decision. +- It does not auto-publish. Always draft → human reviews → human publishes. + +## Reference files + +- `references/lafh-framework.md` — the full theoretical grounding (LAFH's GC-Red methodology, the four roles, the zones, the anti-patterns). Read this when you need to justify a methodological choice or when extending the skill. +- `templates/synthesis-document.md` — template for capability 2. +- `templates/boletin-periodico.md` — template for capability 3. +- `templates/gap-report.md` — template for capability 4. +- `templates/onboarding-capsule.md` — template for capability 5. +- `templates/network-health.md` — template for capability 7. + +## When in doubt + +The default question to ask yourself before producing any output: **"Does this contribute to the production of new collective knowledge, or is it just churn?"** If it's churn, don't produce it. The community's attention is finite. diff --git a/seed-knowledge-manager/agent/README.md b/seed-knowledge-manager/agent/README.md new file mode 100644 index 000000000..23435ba50 --- /dev/null +++ b/seed-knowledge-manager/agent/README.md @@ -0,0 +1,613 @@ +# Knowledge Manager Agent + +Autonomous **Moderador de Redes** (LAFH/GC-Red methodology, see `seed-knowledge-manager/SKILL.md`) for a Seed Hypermedia community. Runs on `oc.hyper.media`. **Governed by Seed documents**, not local config. + +## What this is + +> Headline goal: prove that an agent can be governed by Seed documents — its charter, its allow/deny path rules, its draft-only kill-switch — instead of local YAML/markdown. + +Production deployment connects against community site `hm://z6MkuBbsB1HbSNXLvJCRCrPhimY6g7tzhr4qvcYKPuSZzhno`. Agent identity is `KM_AID = z6Mkh11xNzNLTrkDEjmPf19twBvAVsw3HoQtv5nPKVVbEUSJ`. + +The agent: +- Polls the site every 15 seconds for `@knowledge-manager` and `@` mentions in comments. +- Posts a placeholder reply ("Working on this — back in a moment. ⌛") within ~1–2s of detection so members get a typing-indicator equivalent. +- Searches the community corpus (`seed-cli search`) for documents relevant to the question, fetches them, and feeds them to DeepSeek as grounding context. With `KM_USE_MASTRA_AGENT=1` this becomes a bounded tool-call loop where the model itself decides which docs / threads / profiles to pull (≤30 tool calls before a forced `final_answer`). +- Edits the placeholder in place (or replies in a fresh top-level comment if seed-cli's `--reply` chain breaks) with the final answer, citing hm:// URLs. +- Runs three scheduled cadences via systemd timers — weekly bulletin (Mon 09:00 UTC), gap report (Wed 10:00 UTC), monthly health report (1st of month 09:00 UTC) — each producing a Seed document under `/agents/knowledge-manager/state/...`. +- Captures every action — LLM call, tool call, seed-cli invocation, mention enqueued, reply posted — to a per-run audit directory under `~km/km-logs/runs/`. +- Runs entirely against a fully-subscribed local Seed daemon when `KM_USE_LOCAL_DAEMON=1`. A preflight `site sync-status` check refuses to run unless the daemon has both the subscription and the writer capability blob locally cached. +- Models the per-mention lifecycle (detected → placeholder → agent → finalised) as an XState v5 actor with retry/backoff and JSONL snapshot/replay when `KM_USE_STATE_MACHINE=1`. Killing the service mid-run resumes mid-flight on restart. + +The agent's policy lives entirely in four Seed documents the operator can edit from any desktop client. Toggling `draft_only: true` in the rules doc disables doc-creating writes within ≤60s. The wrapper hardcodes a denylist that prevents the agent from rewriting its own rules. + +## Architecture + +``` +┌──────────────────────────── oc.hyper.media (Ubuntu 24.04) ────────────────────────────┐ +│ │ +│ systemd --user (linger enabled, user "km"): │ +│ │ +│ ┌──────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │ +│ │ seed-daemon.service │ │ km-poll.timer │ │ km-boletin.timer │ │ +│ │ (docker compose) │ │ every 15s │ │ Mon 09:00 UTC │ │ +│ └─────────┬────────────┘ │ → poll-cli.js │ │ → cadence-cli.js │ │ +│ │ └────────────────────┘ └────────────────────┘ │ +│ ▼ ┌────────────────────┐ ┌────────────────────┐ │ +│ ┌──────────────────────┐ │ km-gap.timer │ │ km-health.timer │ │ +│ │ km-seed-daemon │ │ Wed 10:00 UTC │ │ 1st 09:00 UTC │ │ +│ │ km-seed-web (:3000) │ │ → cadence-cli.js │ │ → cadence-cli.js │ │ +│ │ (docker) │ └────────────────────┘ └────────────────────┘ │ +│ └──────────────────────┘ ┌────────────────────┐ │ +│ │ km-telegram.service│ ┌────────────────────┐ │ +│ │ (long-running) │ │ nanobot-gateway │ │ +│ │ → telegram-bot.js │ │ :18791 (optional) │ │ +│ └────────────────────┘ └────────────────────┘ │ +│ │ +│ ~/km-agent/mcp/seed-cli-mcp/dist/ │ +│ poll-cli.js ← mention polling + typing-indicator + grounded reply │ +│ cadence-cli.js ← weekly/monthly LAFH outputs │ +│ telegram-bot.js ← operator chat surface │ +│ index.js ← stdio MCP wrapper used by the (optional) nanobot gateway │ +│ │ +│ ~/km-state/ │ +│ activity-cursor.json processed.jsonl placeholders.jsonl rate-counters.json │ +│ │ +│ ~/km-logs/ runs/____/ index.jsonl current → runs/... │ +│ │ +└────────────────────────────────────────────────────┬──────────────────────────────────┘ + │ + HTTPS │ outbound + ┌──────────────────────────────────────────────┤ + ▼ ▼ + api.deepseek.com hyper.media (P2P + REST) + (one chat completion / answer) (read activity, post comments, + fetch governance, search corpus) +``` + +Components: + +- **Local Seed daemon** (Docker `seedhypermedia/site:latest`, runs as a pure peer): ports `55000` (P2P, public) and `127.0.0.1:55001` HTTP / `:55002` gRPC (loopback). Plus `seed-web:latest` on `127.0.0.1:3000` because `seed-cli` speaks the Remix `/api/` shape, not raw gRPC-Web. With `KM_USE_LOCAL_DAEMON=1` the wrapper points at `http://127.0.0.1:3000` and refuses to run until `seed-cli site sync-status` reports `ready_for_writes=true`. +- **seed-cli** built from this repo's `frontend/apps/cli/` and dropped at `/home/km/.local/bin/seed-cli`. Published `@seed-hypermedia/cli@0.1.4` on npm has an unresolved `workspace:*` dep that breaks `npx`, so we ship a Bun-bundled binary instead. +- **secret-tool shim** at `/home/km/.local/bin/secret-tool` (file-backed, `chmod 600` JSON in `~/.config/seed-keyring/secrets.json`). Replaces `gnome-keyring`/`libsecret`, which can't bootstrap on a headless server. Same on-wire format as the OS keyring entries seed-cli expects. +- **Custom Bun-bundled drivers** (one ~430 KB `dist/index.js` + smaller per-task bundles): `poll-cli.js`, `cadence-cli.js`, `telegram-bot.js`, plus the optional MCP wrapper `index.js` for nanobot. +- **DeepSeek** (`https://api.deepseek.com/v1/chat/completions`) as the LLM. One deterministic chat call per mention in legacy mode; bounded tool-call loop (≤30 calls + mandatory `final_answer`) when `KM_USE_MASTRA_AGENT=1`. +- **XState v5 supervisor** — replaces the implicit two-pass placeholder/finalise loop with explicit per-mention machines, retry/backoff, and crash-resume via JSONL snapshots. Behind `KM_USE_STATE_MACHINE=1`. +- **Mastra-style agent loop** — natural-language chat surface for both Telegram operator/community DMs and the polling finalise step. Re-implements the Mastra slice we need (tool registration → bounded loop → multi-turn history) directly, since the npm SDK's Vite/Hono dep graph does not bundle cleanly with `bun build`. Behind `KM_USE_MASTRA_AGENT=1`. + +We started with HKUDS/nanobot orchestrating the polling loop, but DeepSeek kept getting stuck in `read_file/grep` loops on nanobot's tool-result-spilled-to-disk pattern. The polling driver is now a deterministic Bun script that does one DeepSeek call per question and posts the reply directly. The `nanobot gateway` process can stay running for free-form interactive use, but it is no longer on the critical path. + +## What changed in this iteration + +Three workstreams ship behind feature flags so the legacy paths remain the default until each is verified live. Flip them in `secrets.env` one at a time. + +### Workstream A — Self-contained operation against the local daemon (`KM_USE_LOCAL_DAEMON`) + +Before this change the wrapper called the public gateway (`https://hyper.media`) for every read because the local daemon's smart-sync lagged on capability blobs. We now treat the local daemon as the source of truth. + +**New seed-cli commands** (defined in `frontend/apps/cli/src/commands/site.ts`): + +```bash +# Subscribe the local daemon to a site, recursively. --wait blocks until the +# first DiscoverObject completes (async=false at the gRPC layer). +seed-cli -s http://127.0.0.1:3000 site subscribe hm:// --recursive --wait + +# Drop a subscription. +seed-cli -s http://127.0.0.1:3000 site unsubscribe hm:// + +# Read what the daemon is mirroring. +seed-cli -s http://127.0.0.1:3000 site list-subscriptions + +# Composite check: subscription present + at least one writer cap locally +# cached for the agent. Returns ready_for_writes:true when both hold. +seed-cli -s http://127.0.0.1:3000 site sync-status hm:// --writer z6Mkh11x... + +# Force the daemon's smart-sync to run immediately (wraps Daemon.ForceSync). +seed-cli -s http://127.0.0.1:3000 site reconcile +``` + +Under the hood these wrap the existing `com.seed.activity.v1alpha.Subscriptions` and `Daemon.ForceSync` gRPC RPCs. We exposed them through the Remix `/api/` surface by adding the corresponding entries to `HMGetRequestSchema`/`HMActionSchema` in `frontend/packages/client/src/hm-types.ts` and the implementations under `frontend/packages/shared/src/api-subscriptions.ts` + `api-force-sync.ts`. + +**Bootstrap script** `agent/scripts/bootstrap-subscription.sh` is idempotent — it records the site in `~/km-state/subscribed.flag` after the first subscribe, then waits up to 5 min for the writer capability to converge before exiting. Runs once on first deploy; safe to re-run on any subsequent deploy. + +**Periodic ForceSync** (`km-reconcile.timer`/`.service`) calls `seed-cli site reconcile` every 60 s. This is a userland band-aid; the proper backend fix is below. + +**Backend scheduler change** — `backend/hmnet/syncing/scheduler.go` now extends a subscription task's `hotDeadline` past its `nextRunTime` when `--syncing.subscription-hot-tier=true`. The dispatcher's lazy migration then promotes the task into the hot tier at dispatch time, so capability/comment blobs for subscribed sites no longer compete with cold ephemeral discovery requests. New flag in `backend/config/config.go`: + +```bash +seed-daemon ... --syncing.subscription-hot-tier=true +``` + +Once this rolls out the `km-reconcile.timer` can be removed. + +**Preflight in poll-cli** — when `KM_USE_LOCAL_DAEMON=1`, the driver runs `site sync-status` before the poll loop and exits cleanly (status=`denied`, event `preflight_skipped`) if the local daemon does not yet have the writer cap. Prevents writes against a stale local mirror. + +### Workstream B — XState v5 polling pipeline (`KM_USE_STATE_MACHINE`) + +The legacy two-pass loop in `poll-cli.ts` (Pass A posts placeholders, Pass B finalises) has no formal state, no retry/backoff, and no resume-after-crash semantics beyond idempotency keys. Workstream B replaces Pass B with a per-mention XState v5 actor and a supervisor that persists every transition. + +**Files**: +- `src/machines/mention-machine.ts` — the actor definition. +- `src/machines/supervisor.ts` — actor lifecycle, JSONL persistence, `rehydrate()` for crash-replay. +- `src/machines/poll-driver.ts` — glue called from `poll-cli.ts` Pass B when the flag is on. + +**State graph**: + +``` +detected → enqueued → placeholder_pending → placeholder_posted → +agent_running → draft_ready → finalising → done + + ↓ guards ↓ retries (3, exp backoff 2s base) +skipped_not_allowed agent_backoff / finalise_backoff +cap_exceeded failed_terminal +``` + +Guards on `enqueued` enforce the existing governance limits (`maxCommentsPerRun`, `maxCommentsPerDay`, `moderation.blockedAuthors`) — same `limits.ts` checks as the legacy path, but now first-class transitions with named terminal states. + +**Snapshot / replay**: every state transition appends one JSON line to `${KM_STATE_DIR}/machines/.jsonl`. On startup the supervisor scans the directory, replays each file's events into a fresh actor, and resumes mid-flight. JSONL matches the existing `placeholders.jsonl` / `processed.jsonl` pattern — no new infra, easy to grep, easy to tail. + +**Inspectability**: each transition also emits a trace event (`state_machine_enabled`, `state_machine_rehydrated`) into `trace.jsonl`. `@xstate/inspect` can be enabled in dev for visual debugging. + +### Workstream C — Natural-language agent surface (`KM_USE_MASTRA_AGENT`) + +Replaces the single deterministic DeepSeek call with a bounded tool-call loop. The model is given the question + a set of tools and decides which to call before producing a final answer. This delivers the original goal of "users communicate with the agent in natural language and the agent dynamically expands context (thread roots, linked docs, related search results)". + +**Files**: +- `src/agent/mastra-agent.ts` — the loop. ≤30 tool calls per turn, then a forced `final_answer` step. +- `src/agent/tools-bridge.ts` — in-process tool registry: `seed_search`, `seed_get_doc`, `seed_get_comment_thread`, `seed_get_account_profile`. Each tool wraps a `seed-cli` subprocess. +- `src/agent/prompts.ts` — community vs operator system prompts. +- `src/tools.ts` — same surface also exposed as MCP tools (`seed_get_comment_thread`, `seed_site_sync_status`) for the optional nanobot gateway. + +**Why "Mastra-style" rather than the Mastra SDK directly**: the npm Mastra package depends on Vite + Hono and does not bundle cleanly with `bun build --target node --minify`. We re-implement the small slice we actually need (tool registration → bounded loop → multi-turn history) and keep the surface compatible so a future swap to the SDK is mechanical. See the header of `mastra-agent.ts`. + +**Telegram surface**: when the flag is on, both `/ask` (operator) and free-form DMs route through the Mastra loop with per-chat-id history. The legacy path (single `draftReply` / `draftSystemReply` call) remains as fallback. + +**Polling surface**: `poll-driver.ts` wires the agent into the `agent_running` state of each mention's machine. The supervisor's retry-with-backoff therefore wraps the agent loop — a 429 from DeepSeek triggers the exponential backoff before re-entering `agent_running`. + +**DeepSeek tool-call hardening**: known DeepSeek issues (#244, #336, #946) are mitigated by: +1. Hard tool budget = 30. After 30 calls the model is forced into `final_answer` via `tool_choice: {type: 'function', function: {name: 'final_answer'}}`. +2. Mandatory `final_answer` tool ensures explicit termination instead of "model just stops". +3. Tool results are ≤4 KB (`MAX_DOC_CHARS`) and never paths to disk — so no `read_file/grep` loop pathology. + +## Governance — the four Seed documents + +All four exist on the production site: + +| Doc | Path | Purpose | +| --- | --- | --- | +| `agent-charter` | `/agents/knowledge-manager/charter` | Community purpose, voice, scope, off-topics. Human-editable. | +| `agent-rules` | `/agents/knowledge-manager/rules` | Machine-readable YAML block at `# ----- machine-readable rules begin -----`. Hard policy: allow/deny paths, caps, draft-only kill-switch, mention trigger, invoker source (`writer-capabilities` or `allowlist-doc`), language. | +| `agent-runbook` | `/agents/knowledge-manager/runbook` | Soft instructions on tone, escalation, formatting overrides. | +| `agent-allowlist` | `/agents/knowledge-manager/allowlist` | Optional invoker list when `mentions.invoker_source: allowlist-doc`. | + +Edit any of them from desktop. The agent re-reads them on every run (60 s TTL cache). The wrapper additionally hardcodes a denylist over those four paths so the agent itself cannot rewrite its own constraints. + +### Kill switch + +In `agent-rules`, set `draft_only: true`. Within 60 s the cadence drivers will refuse `seed-cli document create` calls and `poll-cli` will continue posting comments only (no doc writes). To force immediate refresh: `systemctl --user restart nanobot-gateway` on the server (clears the in-process cache). + +## Operator — quick reference + +```bash +ssh ubuntu@oc.hyper.media + +# All systemd state belongs to user `km`. +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user list-timers + +# Watch the latest run live. +sudo -u km bash -lc '/home/km/.local/bin/km-log tail' + +# Recent runs. +sudo -u km bash -lc '/home/km/.local/bin/km-log latest 10' + +# Print a specific run. +sudo -u km bash -lc '/home/km/.local/bin/km-log show 01KQYG…' + +# Find all runs that touched a comment id. +sudo -u km bash -lc '/home/km/.local/bin/km-log mention z6Gd...' + +# Force an immediate poll (typing-indicator pattern still applies). +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user start km-poll.service + +# Force a weekly bulletin / gap / health right now. +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user start km-boletin.service +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user start km-gap.service +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user start km-health.service + +# Restart the optional nanobot gateway (also clears the rules cache). +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) systemctl --user restart nanobot-gateway +``` + +Logs are `chmod 700` under `/home/km/km-logs/`. Each run dir contains: + +``` +meta.json trigger, KM_AID, start, end, wall_ms, status, counters +trace.jsonl ordered events (governance_loaded, mention_enqueued, placeholder_posted, reply_finalised, …) +llm.jsonl DeepSeek prompts + completions + tokens + latency +seed-cli.jsonl every shell-out: argv, exit code, stdout, stderr (truncated, redacted) +stdout.log / stderr.log raw process streams +``` + +`index.jsonl` at the top level carries one summary line per run for tail-grepping. + +Logrotate config under `/home/km/.config/logrotate.d/km-logs.conf` keeps 30 days / 5 GB. + +## Observability Center + +The Bun app in `../observability-center/` turns the audit/state-machine trail into an operator console for questions like: + +- “Why did KM not answer this comment?” +- “How many actors are alive now?” +- “What is KM currently doing?” +- “How is syncing working?” + +It runs separately from the agent and can sit behind `https://oc.hyper.media`: + +```bash +cd seed-knowledge-manager/observability-center +bun install +OC_DB_PATH=/home/km/oc-data/oc.sqlite \ +OC_INGEST_TOKEN= \ +OC_IMPORT_KM_LOGS_DIR=/home/km/km-logs \ +OC_IMPORT_KM_STATE_DIR=/home/km/km-state \ +OC_IMPORT_FULL_PAYLOAD=0 \ +bun src/main.ts serve +``` + +Wire live telemetry from the poll daemon: + +```bash +KM_OBS_URL=https://oc.hyper.media/api/ingest +KM_OBS_TOKEN= +KM_OBS_FULL_PAYLOAD=0 +``` + +`KM_OBS_FULL_PAYLOAD=0` and `OC_IMPORT_FULL_PAYLOAD=0` are the defaults: LLM prompts/full tool payloads are dropped or previewed, while IDs, states, counters, timings, and short redacted previews are preserved. Set either to `1` only for a short debugging session. + +## End-to-end setup (bootstrap from scratch) + +These are the as-built steps, in order. Anything we discovered along the way that diverges from the original plan is captured in **bold notes**. + +### 1. Server prep + +Ubuntu 24.04, Docker present. + +```bash +ssh ubuntu@oc.hyper.media + +sudo apt update +sudo apt install -y \ + python3.12 python3.12-venv pipx \ + libsecret-1-0 libsecret-tools dbus-user-session bubblewrap \ + jq curl rsync logrotate gnome-keyring + +# **NOTE:** Ubuntu's stock `nodejs` (18.x) ships with npm 9 which can't +# install packages with `workspace:*` deps. Replace with NodeSource 22. +sudo apt remove -y nodejs npm libnode-dev +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash - +sudo apt install -y nodejs + +# Create the agent user. +sudo useradd --create-home --shell /bin/bash km +sudo usermod -aG docker km +sudo loginctl enable-linger km +sudo install -d -m 700 -o km -g km \ + /home/km/.local /home/km/.local/state /home/km/.local/share \ + /home/km/.local/bin /home/km/.cache /home/km/.config \ + /home/km/.config/systemd/user /home/km/.config/logrotate.d \ + /home/km/.config/seed-keyring /home/km/.secrets \ + /home/km/.nanobot /home/km/.nanobot/workspace \ + /home/km/seed-daemon /home/km/seed-daemon/data /home/km/seed-daemon/web-data \ + /home/km/km-agent /home/km/km-agent/mcp/seed-cli-mcp/dist \ + /home/km/km-state /home/km/km-logs +``` + +### 2. Local Seed daemon + web + +Compose file at `/home/km/seed-daemon/compose.yaml` (deployed by `agent/seed-daemon/compose.yaml` from this repo). Two containers: + +- `km-seed-daemon` (image `seedhypermedia/site:latest`) on `127.0.0.1:55001-55002` + public `:55000`. +- `km-seed-web` (image `seedhypermedia/web:latest`) on `127.0.0.1:3000` — necessary because `seed-cli` speaks `/api/` over the Remix server, not raw gRPC-Web. + +The web container needs `web-data/config.json`. We seed it with `{}`: + +```bash +sudo install -m 644 -o km -g km <(echo '{}') /home/km/seed-daemon/web-data/config.json +``` + +Systemd user unit `seed-daemon.service` orchestrates `docker compose up -d` (see `agent/systemd/seed-daemon.service`). + +> **NOTE — original plan vs. as-built:** The plan started with daemon-only at `127.0.0.1:55001`. We then learned `seed-cli` requires the Remix `/api/` surface, so a `seed-web` container was added. **The wrapper currently still points at `https://hyper.media` via `SEED_SERVER` because the local daemon's smart-syncing lags on capability blobs.** Switch to `http://127.0.0.1:3000` once you have a way to force-pull the site root and its capability/contact graph. + +> **NOTE — daemon keystore:** The compose command must include `-keystore-dir=/data/keys`. Without it the daemon's vault.NewProduction tries to talk to libsecret/dbus inside the container, which doesn't exist. Resulting in `failed to create production keystore: failed reading vault credentials from keyring: exec: "dbus-launch": executable file not found in $PATH`. + +### 3. seed-cli on the host + +The published `@seed-hypermedia/cli@0.1.4` on npm has an unresolved `workspace:*` dep on `@seed-hypermedia/client`, so `npx -y @seed-hypermedia/cli` fails. Build from this repo with Bun on your Mac (`bun run build` in `frontend/apps/cli/`) and ship the bundled `dist/index.js`: + +```bash +# From your local repo: +scp frontend/apps/cli/dist/index.js ubuntu@oc.hyper.media:/tmp/seed-cli.js +ssh ubuntu@oc.hyper.media ' + sudo install -d -m 755 -o km -g km \ + /home/km/.local/share/seed-cli /home/km/.local/share/seed-cli/dist + sudo install -m 755 -o km -g km /tmp/seed-cli.js \ + /home/km/.local/share/seed-cli/dist/index.js + sudo install -m 644 -o km -g km <(echo "{\"name\":\"@seed-hypermedia/cli\",\"version\":\"0.1.1\",\"type\":\"module\",\"bin\":{\"seed-cli\":\"./dist/index.js\"}}") \ + /home/km/.local/share/seed-cli/package.json + sudo ln -sf /home/km/.local/share/seed-cli/dist/index.js /home/km/.local/bin/seed-cli + sudo chown -h km:km /home/km/.local/bin/seed-cli + sudo rm /tmp/seed-cli.js +' +``` + +> **NOTE — version-lookup:** seed-cli does `readFileSync('../package.json', import.meta.url)` to read its own version. The `package.json` next to the bundled `dist/` is required, even if minimal. + +### 4. Headless Linux keyring shim + +`gnome-keyring`'s default collection won't initialise without a graphical session, so `secret-tool` fails with `Object does not exist at path /org/freedesktop/secrets/collection/login`. We replace `secret-tool` with a Bash shim that stores keys in a mode-600 JSON file: + +```bash +scp seed-knowledge-manager/agent/scripts/secret-tool-shim ubuntu@oc.hyper.media:/tmp/secret-tool +ssh ubuntu@oc.hyper.media ' + sudo install -m 755 -o km -g km /tmp/secret-tool /home/km/.local/bin/secret-tool + sudo rm /tmp/secret-tool +' +``` + +The shim emits `not found` to stderr on lookup miss so seed-cli's keyring.ts treats it as "no key" rather than as an error. It is on PATH before `/usr/bin/secret-tool`. + +### 5. Generate the agent identity + +```bash +ssh ubuntu@oc.hyper.media ' + sudo -u km bash -lc " + /home/km/.local/bin/seed-cli key generate \ + --name knowledge-manager --show-mnemonic \ + > /home/km/.secrets/knowledge-manager.mnemonic 2>&1 + chmod 600 /home/km/.secrets/knowledge-manager.mnemonic + /home/km/.local/bin/seed-cli key list + " +' +``` + +> **Pull the mnemonic to your Mac, store in a vault (KeePass / paper) and `shred -u` the on-server copy.** Mnemonic = root signing key. Do not paste in chat or commit. It can be re-imported into the Seed Vault to set the agent's profile name + avatar. + +### 6. Capability grant + +The owner of the site root key issues a WRITER capability on `--path /` for `KM_AID`. From the owner's machine: + +```bash +seed-cli capability create \ + --delegate z6Mkh11xNzNLTrkDEjmPf19twBvAVsw3HoQtv5nPKVVbEUSJ \ + --role WRITER --path / --label knowledge-manager \ + --key +``` + +Verify on the gateway: `seed-cli account capabilities hm://` should now list the new delegate. + +> **NOTE — desktop UI vs. on-chain truth:** The desktop app's "members" panel may show writer status regardless of capability blob propagation. Always verify with `seed-cli account capabilities hm://`. Comments from accounts that *appear* to be writers in desktop but aren't on the gateway are correctly skipped by the wrapper. + +> **NOTE — agent profile:** The agent's profile (name, avatar) is account metadata published by the Seed Vault, not a document at the account root. To set it: import the agent's mnemonic into `https://hyper.media/vault`, click the Knowledge Manager account, hit "Edit Profile". Set name + description + icon. After ~30s of P2P sync, all sites resolve `` to "Knowledge Manager". + +### 7. Bootstrap governance documents + +If the four governance docs don't exist on the site, create them. Either run as the agent (auto-creates from the templates in `agent/templates/`) or as a writer: + +```bash +SITE=z6MkuBbsB1HbSNXLvJCRCrPhimY6g7tzhr4qvcYKPuSZzhno +KEY= +TPL=seed-knowledge-manager/agent/templates + +for slug in charter rules runbook allowlist; do + TITLE="Knowledge Manager — $(echo $slug | sed 's/.*/\u&/')" + seed-cli document create \ + --account "$SITE" \ + --path "/agents/knowledge-manager/$slug" \ + --file "$TPL/agent-$slug.md" \ + --name "$TITLE" \ + --key "$KEY" +done +``` + +Also publish parent index docs at `/agents` and `/agents/knowledge-manager` for navigation. + +### 8. Build + deploy the wrapper drivers + +On your Mac, in `seed-knowledge-manager/agent/mcp/seed-cli-mcp/`: + +```bash +bun install +bun test src +bun run typecheck +bun run build # produces dist/{index,poll-cli,cadence-cli,telegram-bot}.js +``` + +Ship the four bundles to the server: + +```bash +scp seed-knowledge-manager/agent/mcp/seed-cli-mcp/dist/*.js \ + ubuntu@oc.hyper.media:/tmp/ +ssh ubuntu@oc.hyper.media ' + sudo install -m 755 -o km -g km /tmp/index.js /home/km/km-agent/mcp/seed-cli-mcp/dist/index.js + sudo install -m 755 -o km -g km /tmp/poll-cli.js /home/km/km-agent/mcp/seed-cli-mcp/dist/poll-cli.js + sudo install -m 755 -o km -g km /tmp/cadence-cli.js /home/km/km-agent/mcp/seed-cli-mcp/dist/cadence-cli.js + sudo install -m 755 -o km -g km /tmp/telegram-bot.js /home/km/km-agent/mcp/seed-cli-mcp/dist/telegram-bot.js + sudo rm /tmp/{index,poll-cli,cadence-cli,telegram-bot}.js +' +``` + +The skill methodology (`SKILL.md`, `references/`, `templates/`) is rsynced into `/home/km/.nanobot/workspace/skill/` for the optional nanobot gateway. The polling/cadence drivers don't read it directly — the LAFH framing lives in their hardcoded system prompts. + +### 9. Secrets + systemd units + +`/home/km/.nanobot/secrets.env` (mode 600): + +``` +DEEPSEEK_API_KEY=sk-... # required for replies + cadenced docs +SEED_SERVER=http://127.0.0.1:3000 # local daemon when KM_USE_LOCAL_DAEMON=1; otherwise gateway +SEED_LOCAL_DAEMON_URL=http://127.0.0.1:3000 # used by km-reconcile.service + bootstrap-subscription.sh +SEED_SITE=hm://z6MkuBbsB1HbSNXLvJCRCrPhimY6g7tzhr4qvcYKPuSZzhno +KM_KEY_NAME=knowledge-manager +KM_AID=z6Mkh11xNzNLTrkDEjmPf19twBvAVsw3HoQtv5nPKVVbEUSJ # gates ready_for_writes preflight +TELEGRAM_TOKEN=... # only needed for km-telegram.service +OPS_TELEGRAM_ID=12345,67890 # comma-sep allowlist of operator chat IDs + +# Workstream feature flags. All default off → legacy paths. +KM_USE_LOCAL_DAEMON=1 # poll-cli refuses to run unless site sync-status reports ready +KM_USE_STATE_MACHINE=1 # Pass B routes through XState supervisor with retry/backoff +KM_USE_MASTRA_AGENT=1 # Telegram + finalisation use bounded tool-call loop +``` + +Each flag is independent. Bring them up in order A → B → C, verifying live for ~24h between flips. + +Systemd user units (all under `~/.config/systemd/user/`): + +``` +seed-daemon.service # the docker compose stack (Phase 1) +nanobot-gateway.service # optional MCP gateway (Phases 4–6 development) +km-poll.timer + .service # mention polling, every 15s +km-boletin.timer + .service # weekly bulletin, Mon 09:00 UTC +km-gap.timer + .service # gap report, Wed 10:00 UTC +km-health.timer + .service # network health, 1st 09:00 UTC +km-telegram.service # operator Telegram bot (long-running) +km-reconcile.timer + .service # periodic ForceSync against the local daemon (every 60s) +``` + +Enable + start everything: + +```bash +sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) bash -lc ' + systemctl --user daemon-reload + systemctl --user enable --now seed-daemon.service + systemctl --user enable --now km-poll.timer km-boletin.timer km-gap.timer km-health.timer km-reconcile.timer + systemctl --user enable --now km-telegram.service + systemctl --user list-timers +' +``` + +Bootstrap the subscription (once, after seed-daemon comes up healthy): + +```bash +sudo -u km bash /home/km/km-agent/scripts/bootstrap-subscription.sh \ + hm://z6MkuBbsB1HbSNXLvJCRCrPhimY6g7tzhr4qvcYKPuSZzhno \ + z6Mkh11xNzNLTrkDEjmPf19twBvAVsw3HoQtv5nPKVVbEUSJ +``` + +> **NOTE — port 18790 collision:** The default nanobot gateway port `18790` is already taken by another service on `oc.hyper.media`. We pin it to `18791` via `--port 18791` in `nanobot-gateway.service`. Adjust if your host has different conflicts. + +### 10. Telegram bot + +Get a bot token from `@BotFather` and your numeric chat ID from `@userinfobot`. Drop both into `secrets.env` (`TELEGRAM_TOKEN`, `OPS_TELEGRAM_ID`). Restart `km-telegram.service`. From your phone, message the bot `/help`. Available commands: `/status`, `/last-runs`, `/show-rules`, `/poll-now`. Mutations to governance docs are intentionally NOT exposed — edit them from desktop instead. + +> **NOTE — original plan**: Phase 7 originally targeted nanobot's built-in Telegram channel. Since we ditched nanobot from the polling path, we replaced it with a 130-line Bun bot that long-polls the Telegram REST API directly. Same security guarantee (allowFrom enforced), much simpler. + +## Verification matrix (Phase 8) + +End-to-end checks. ✅ = verified live on production. + +| # | Check | Status | +| --- | --- | --- | +| 1 | Local Seed daemon healthy | ✅ `curl http://127.0.0.1:55001/debug/version` returns build info | +| 2 | Daemon survives reboot | ✅ Container `Up 47s` after host reboot, HTTP OK on first probe | +| 3 | seed-cli round-trip via local web server | ✅ `seed-cli -s http://127.0.0.1:3000 account list` returns `{"accounts":[]}` | +| 4 | secret-tool shim works | ✅ `seed-cli key list` returns the agent key | +| 5 | Agent identity on gateway | ✅ `account get z6Mkh11x...KVVbEUSJ` returns name "Knowledge Manager" + avatar | +| 6 | WRITER capability for KM_AID | ✅ `account capabilities hm://` lists `z6Mkh11x...` (created 2026-05-05T21:49:33Z) | +| 7 | All four governance docs exist | ✅ /agents/knowledge-manager/{charter,rules,runbook,allowlist} resolve via gateway | +| 8 | MCP wrapper unit tests | ✅ `bun test src` — 44 tests / 91 expects, all green | +| 9 | Polling loop end-to-end with citation | ✅ Comment by `z6Mkvz9...` mentioning KM in lobby thread → placeholder within ~5s → finalised within ~15s with site context cited | +| 10 | Site-root mention also triggers reply | ✅ Comment mentioning the site (not KM directly) is picked up because agent holds WRITER cap | +| 11 | Non-writer mention skipped | ✅ Comment by `z6MkvqBa...` (no cap) → `mention_skipped_not_allowed` recorded | +| 12 | Typing-indicator (placeholder → edit) | ✅ Same comment id morphs from "Working on this — back in a moment. ⌛" to the final answer | +| 13 | Site search injection | ✅ `site_context_collected` event records `urls` array; reply text includes hm:// links to relevant docs | +| 14 | Weekly bulletin doc published | ✅ `/agents/knowledge-manager/state/boletin/2026-W19` | +| 15 | Gap report doc published | ✅ `/agents/knowledge-manager/state/gaps/2026-05-06` | +| 16 | Network health doc published | ✅ `/agents/knowledge-manager/state/network-health/2026-05` | +| 17 | `draft_only: true` blocks doc writes | ✅ Cadence runs return `denied` with `write_blocked_by_rules` event when toggled | +| 18 | Hardcoded denylist refuses self-edits | ✅ Path `/agents/knowledge-manager/rules` → `hardcoded-deny` (verified in `limits.test.ts`) | +| 19 | Audit log per run | ✅ Each invocation produces `meta.json` + `trace.jsonl` + `llm.jsonl` + `seed-cli.jsonl` | +| 20 | km-log helper works | ✅ `km-log latest 5`, `km-log show `, `km-log mention ` all functional | +| 21 | Secrets redaction | ✅ Grepping `km-logs/` for `DEEPSEEK_API_KEY` value returns 0 hits | +| 22 | Telegram allowFrom enforced | ✅ Non-allowlisted chat IDs are silently ignored | +| 23 | `seed-cli site subscribe --recursive --wait` returns | new — verify with `site list-subscriptions` showing the site | +| 24 | `seed-cli site sync-status hm:// --writer ` reports `ready_for_writes:true` | new — only true once writer cap converges locally | +| 25 | `KM_USE_LOCAL_DAEMON=1` preflight skips when not ready | new — `tcpdump -i any host hyper.media` shows zero traffic during the skipped run | +| 26 | `KM_USE_LOCAL_DAEMON=1` preflight passes when ready | new — `trace.jsonl` shows `preflight_sync_status` with `ready:true` and a normal poll cycle follows | +| 27 | Subscription hot-tier promotion | new — with `--syncing.subscription-hot-tier=true` capability blobs converge in ~1 minute instead of ~5 minutes | +| 28 | XState rehydrate after crash | new — kill `km-poll.service` mid-mention, restart, see `state_machine_rehydrated` event in `trace.jsonl` and the mention completes | +| 29 | XState retry-with-backoff | new — temporarily set `DEEPSEEK_API_KEY=invalid`, see `agent_running → agent_backoff → agent_running` (×3) in `${stateDir}/machines/.jsonl`, then `failed_terminal` | +| 30 | XState cap_exceeded | new — set `maxCommentsPerDay=2` in agent-rules; third mention transitions to `cap_exceeded` | +| 31 | Mastra tool-call loop | new — `tools.jsonl` shows ordered calls (`seed_search` → `seed_get_doc` → `seed_get_comment_thread` → `final_answer`) within budget | +| 32 | Mastra tool budget enforced | new — model exceeding 30 calls is forced into `final_answer` via `tool_choice` | +| 33 | Telegram multi-turn community Q&A | new — three follow-up messages share context across turns when `KM_USE_MASTRA_AGENT=1` | +| 34 | `seed_get_comment_thread` MCP tool | new — `bun test src` covers thread walk; production check: `tools.jsonl` shows the call when a mention is in a reply chain | + +## Known issues + workarounds + +- **Local daemon capability blob lag — superseded.** Previous workaround pinned `SEED_SERVER=https://hyper.media`. Now addressed by `seed-cli site subscribe`, the `--syncing.subscription-hot-tier` daemon flag, and the `KM_USE_LOCAL_DAEMON` preflight gate. Periodic `km-reconcile.timer` is the userland band-aid until the hot-tier change is verified live. +- **`seed-cli comment create --reply ` returns `✗ Non-base58btc character` for some parents.** Reproduces specifically when the parent comment's chain includes an edited comment. `poll-cli.ts` retries without `--reply` (top-level reply on the doc) and logs `placeholder_reply_fallback`. Filed in our internal seed-cli backlog. +- **`seed-cli document create --path /` returns `HTTP 500 from PublishBlobs`.** The CLI treats `--path ""` as falsy (slugifies the title). The Seed Vault publishes the agent's home-doc/profile metadata via a different RPC; the CLI can't currently publish at the account root. +- **`seed-cli` writes success messages to stderr.** `comment create` prints `✓ Comment published: ` to stderr (not stdout), and the CID is the version, not the record id. `postPlaceholder` parses stderr, then resolves CID → record id via `comment get`. +- **Activity feed `--resource` is exact-match.** Filtering by the site root returns only events directly on the root doc, not on subdocuments (`/discussions/*`, `/agents/*`). The wrapper now pulls the unfiltered feed and post-filters by `comment.targetAccount`. +- **DeepSeek + nanobot don't compose for polling.** nanobot saves large tool-results to `~/.nanobot/workspace/.nanobot/tool-results/*.txt` and presents that to the LLM; DeepSeek then loops on `read_file`/`grep` instead of replying. The polling driver bypasses nanobot for this reason. +- **Cursor model.** The activity feed paginates reverse-chronologically; cursor token = "next older page", not "since last poll". State stores the newest event id we've classified and stops walking when the loop hits it. Field name `lastEventId` (was `token` in earlier versions). + +## Layout reference + +``` +agent/ +├── README.md ← this file +├── config/ ← optional nanobot config (used by phases 4–5 dev) +│ └── config.json +├── seed-daemon/ ← docker compose for the local stack +│ └── compose.yaml +├── mcp/seed-cli-mcp/ ← Bun-built drivers + MCP wrapper +│ ├── package.json +│ ├── tsconfig.json +│ ├── src/ +│ │ ├── audit.ts +│ │ ├── cadence-cli.ts ← weekly bulletin / gap / health driver +│ │ ├── config.ts ← env → AgentConfig (incl. KM_USE_* flags) +│ │ ├── governance.ts +│ │ ├── index.ts ← stdio MCP server entry point (optional) +│ │ ├── limits.ts +│ │ ├── mentions.ts +│ │ ├── poll-cli.ts ← polling + typing-indicator + grounded reply +│ │ ├── reply-engine.ts ← legacy single-shot DeepSeek call +│ │ ├── redact.ts +│ │ ├── seedcli.ts +│ │ ├── state.ts +│ │ ├── telegram-bot.ts ← operator chat surface +│ │ ├── tools.ts ← MCP tool registry (incl. seed_get_comment_thread, seed_site_sync_status) +│ │ ├── machines/ +│ │ │ ├── mention-machine.ts ← XState v5 actor: per-mention lifecycle +│ │ │ ├── supervisor.ts ← actor supervisor + JSONL snapshot/replay +│ │ │ └── poll-driver.ts ← glue from poll-cli Pass B → supervisor +│ │ ├── agent/ +│ │ │ ├── mastra-agent.ts ← bounded DeepSeek tool-call loop (multi-turn) +│ │ │ ├── tools-bridge.ts ← in-process tool registry for the agent +│ │ │ └── prompts.ts ← community + operator system prompts +│ │ └── *.test.ts ← bun:test unit tests +│ └── dist/ ← bun build output (deployed to server) +├── systemd/ ← user-mode unit files +│ ├── seed-daemon.service +│ ├── nanobot-gateway.service ← optional, port 18791 +│ ├── km-poll.{service,timer} +│ ├── km-boletin.{service,timer} +│ ├── km-gap.{service,timer} +│ ├── km-health.{service,timer} +│ ├── km-reconcile.{service,timer} ← periodic ForceSync against local daemon +│ └── km-telegram.service +├── scripts/ +│ ├── install-phase1.sh ← idempotent server provisioning +│ ├── bootstrap-subscription.sh ← idempotent site subscribe + writer-cap wait +│ ├── km-log ← log browsing helper for /home/km/.local/bin +│ └── secret-tool-shim ← file-backed libsecret replacement +├── templates/ ← bootstrap content for the four governance docs +│ ├── agent-charter.md +│ ├── agent-rules.md +│ ├── agent-runbook.md +│ └── agent-allowlist.md +└── logrotate/ + └── km-logs.conf +``` diff --git a/seed-knowledge-manager/agent/config/.gitkeep b/seed-knowledge-manager/agent/config/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/seed-knowledge-manager/agent/config/config.json b/seed-knowledge-manager/agent/config/config.json new file mode 100644 index 000000000..965697e6e --- /dev/null +++ b/seed-knowledge-manager/agent/config/config.json @@ -0,0 +1,54 @@ +{ + "providers": { + "deepseek": { + "apiKey": "${DEEPSEEK_API_KEY}" + } + }, + "agents": { + "defaults": { + "provider": "deepseek", + "model": "deepseek-chat", + "systemPrompt": "You are the Knowledge Manager for the Seed community at ${SEED_SITE}. Methodology lives in workspace/skill/SKILL.md.\n\nHard rules:\n- Cap per-run: 1 document, 5 comments. Cap per-day comments: 30.\n- If draft_only=true, never create a document — only comments.\n- Default language: en.\n\n=== When you receive the literal command `/poll-mentions` ===\n\nUse ONLY the following tools, in this exact sequence. Do NOT call exec, read_file, web_search, or any non-mcp_seed_* tool. Do NOT do additional research before replying.\n\n STEP 1. Call mcp_seed_poll_collect (no args). It returns `{pending: [{mention, target}]}`. The mention object already contains everything you need: `mention.text` is the user's full question, `mention.author` is the asker.\n\n STEP 2. For EACH entry in `pending`:\n a) Draft a 2–4 sentence reply. Use only what you can infer from `mention.text` plus your general knowledge. If you don't know the answer, acknowledge that and suggest where the asker might look. Reply must be plain text (no markdown headers, no code blocks). Keep it under 80 words.\n b) If `target.replyTo` is set, call mcp_seed_seed_reply_comment with {targetId: target.targetId, parentCommentId: target.replyTo, body: }. Otherwise call mcp_seed_seed_create_comment with {targetId: target.targetId, body: }.\n c) Call mcp_seed_inbox_mark_done with {mention: , runId: , status: 'replied'} (or 'error' if the previous call failed).\n\n STEP 3. Output ONE final line (plain text) summarising: events scanned, enqueued, replied, skipped.\n\nDo not call seed_search, seed_get_document, seed_list_comments, seed_get_activity, or any other tool during /poll-mentions. They are for free-form requests only.\n\n=== When invoked with any other prompt ===\n\nTreat it as a user request and answer using SKILL.md methodology. Free-form research tools are allowed.", + "temperature": 0.2, + "timezone": "UTC", + "idleCompactAfterMinutes": 15, + "disabledSkills": [ + "file_tools", + "read_file", + "write_file", + "edit_file", + "grep", + "glob", + "shell", + "exec", + "web" + ] + } + }, + "tools": { + "restrictToWorkspace": true, + "exec": { + "enable": false + }, + "web": { + "enable": false + }, + "mcpServers": { + "seed": { + "command": "node", + "args": ["/home/km/km-agent/mcp/seed-cli-mcp/dist/index.js"], + "toolTimeout": 60, + "env": { + "SEED_SERVER": "${SEED_SERVER}", + "SEED_SITE": "${SEED_SITE}", + "KM_KEY_NAME": "${KM_KEY_NAME}", + "KM_STATE_DIR": "/home/km/km-state", + "KM_LOGS_DIR": "/home/km/km-logs", + "SEED_CLI_PATH": "/home/km/.local/bin/seed-cli", + "PATH": "/home/km/.local/bin:/usr/bin:/usr/local/bin" + } + } + } + }, + "channels": {} +} diff --git a/seed-knowledge-manager/agent/logrotate/.gitkeep b/seed-knowledge-manager/agent/logrotate/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/seed-knowledge-manager/agent/logrotate/km-logs.conf b/seed-knowledge-manager/agent/logrotate/km-logs.conf new file mode 100644 index 000000000..12a4ed302 --- /dev/null +++ b/seed-knowledge-manager/agent/logrotate/km-logs.conf @@ -0,0 +1,33 @@ +# User-mode logrotate config for the Knowledge Manager agent. +# Install path: /home/km/.config/logrotate.d/km-logs.conf +# Wired to a user-systemd timer (km-logrotate.timer) that runs daily. + +/home/km/km-logs/runs/*/trace.jsonl +/home/km/km-logs/runs/*/llm.jsonl +/home/km/km-logs/runs/*/tools.jsonl +/home/km/km-logs/runs/*/seed-cli.jsonl +/home/km/km-logs/runs/*/stdout.log +/home/km/km-logs/runs/*/stderr.log +{ + daily + rotate 30 + maxsize 100M + missingok + notifempty + compress + delaycompress + nocopytruncate + su km km +} + +# Top-level summary index. +/home/km/km-logs/index.jsonl { + yearly + rotate 5 + missingok + notifempty + compress + delaycompress + nocopytruncate + su km km +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/.gitignore b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/.gitignore new file mode 100644 index 000000000..06e60381b --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.tsbuildinfo diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/bun.lock b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/bun.lock new file mode 100644 index 000000000..6ba42b485 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/bun.lock @@ -0,0 +1,219 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@km/seed-cli-mcp", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "ulid": "^2.4.0", + "xstate": "^5.19.0", + "yaml": "^2.6.0", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.0", + }, + }, + }, + "packages": { + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.5.0", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "hono": ["hono@4.12.17", "", {}, "sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "ulid": ["ulid@2.4.0", "", { "bin": { "ulid": "bin/cli.js" } }, "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xstate": ["xstate@5.31.0", "", {}, "sha512-5B+0DqC0uNUrcLUEY3pn3iNy+swvK2E0ZpYp5gnV3oxMX5y87vzXkU5YXv9CAtyG5c5FOJ1SzvTWHrwE8fMZNQ=="], + + "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + } +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/package.json b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/package.json new file mode 100644 index 000000000..311451243 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/package.json @@ -0,0 +1,33 @@ +{ + "name": "@km/seed-cli-mcp", + "version": "0.1.0", + "private": true, + "description": "stdio MCP server wrapping seed-cli for the Knowledge Manager agent.", + "type": "module", + "bin": { + "seed-cli-mcp": "./dist/index.js" + }, + "main": "./dist/index.js", + "scripts": { + "build": "bun build src/index.ts --target node --outdir dist --minify && bun build src/poll-cli.ts --target node --outdir dist --minify --outfile poll-cli.js && bun build src/cadence-cli.ts --target node --outdir dist --minify --outfile cadence-cli.js && bun build src/telegram-bot.ts --target node --outdir dist --minify --outfile telegram-bot.js", + "build:dev": "bun build src/index.ts --target node --outdir dist --external '@modelcontextprotocol/sdk' --external 'yaml' --external 'zod' --external 'ulid'", + "typecheck": "bunx tsc -p tsconfig.json --noEmit", + "test": "bun test src", + "test:watch": "bun test --watch src" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "yaml": "^2.6.0", + "zod": "^3.23.8", + "ulid": "^2.4.0", + "xstate": "^5.19.0" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.0" + }, + "engines": { + "bun": ">=1.0", + "node": ">=20" + } +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/mastra-agent.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/mastra-agent.ts new file mode 100644 index 000000000..99dffaf1c --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/mastra-agent.ts @@ -0,0 +1,316 @@ +/** + * Mastra-style agent loop for the Knowledge Manager. + * + * Why "Mastra-style" rather than the Mastra SDK directly: Mastra ships as an + * npm package with a deep dependency on Vite + Hono. Bundling it into the + * minified Bun-built `dist/*.js` we ship to the server hits import-graph + * problems. We re-implement the small slice of Mastra we actually need: + * + * - tool registration (JSON-Schema → DeepSeek `tools` parameter) + * - bounded tool-call loop (max 30 calls / final_answer terminator) + * - per-thread message history (Telegram chat id, mention id) + * + * If/when the Mastra runtime gets first-class Bun support, we replace the + * inner loop with `agent.run({threadId, message})` keeping the tool surface. + */ + +import type {SeedCli} from '../seedcli.js' +import type {AuditRun} from '../audit.js' +import type {Mention} from '../mentions.js' +import {buildAgentTools, type ToolDef} from './tools-bridge.js' +import {COMMUNITY_AGENT_SYSTEM, OPERATOR_AGENT_SYSTEM} from './prompts.js' + +/** Maximum tool calls before we force the model to emit final_answer. We + * used to allow 30 but DeepSeek bursts 2-3 calls per step and the budget + * was exhausted before the model ever decided to terminate. 10 is enough + * for a single search + a few doc fetches + final_answer. */ +const MAX_TOOL_CALLS = 10 +/** When the running count crosses this threshold the next API call locks + * `tool_choice` to final_answer so the model is forced to summarise. */ +const FORCE_FINAL_THRESHOLD = MAX_TOOL_CALLS - 2 +const DEEPSEEK_URL = 'https://api.deepseek.com/v1/chat/completions' + +type Role = 'system' | 'user' | 'assistant' | 'tool' +type Message = { + role: Role + content: string | null + name?: string + tool_calls?: Array<{ + id: string + type: 'function' + function: {name: string; arguments: string} + }> + tool_call_id?: string +} + +type RunArgs = { + systemPrompt: string + userMessage: string + history?: Message[] + tools: ToolDef[] + audit?: AuditRun + maxTokens?: number + temperature?: number +} + +type RunResult = { + finalAnswer: string | null + /** Updated history including system + user + assistant + tool messages. + * Caller persists it per thread to support multi-turn. */ + history: Message[] + toolCallCount: number +} + +async function runAgent(args: RunArgs): Promise { + const apiKey = process.env.DEEPSEEK_API_KEY + if (!apiKey) { + args.audit?.trace({ts: new Date().toISOString(), level: 'error', event: 'mastra_no_deepseek_key'}) + return {finalAnswer: null, history: args.history ?? [], toolCallCount: 0} + } + + // Mandatory final_answer tool ensures the model terminates explicitly. + const allTools: ToolDef[] = [ + ...args.tools, + { + name: 'final_answer', + description: 'Emit the final reply to the user. Call this exactly once when ready.', + parameters: { + type: 'object', + properties: { + body: {type: 'string', description: 'The reply body. Plain text or simple markdown.'}, + }, + required: ['body'], + }, + handler: async () => '', + }, + ] + + const tools = allTools.map((t) => ({ + type: 'function' as const, + function: {name: t.name, description: t.description, parameters: t.parameters}, + })) + + const messages: Message[] = [ + {role: 'system', content: args.systemPrompt}, + ...(args.history ?? []), + {role: 'user', content: args.userMessage}, + ] + + let toolCallCount = 0 + let finalAnswer: string | null = null + + for (let step = 0; step < MAX_TOOL_CALLS + 2; step++) { + const t0 = Date.now() + // Gate on cumulative toolCallCount (not step index) — the model often + // bursts 2-3 tool calls per step, so a step-indexed gate fires too late. + const forceFinal = toolCallCount >= FORCE_FINAL_THRESHOLD + const body = JSON.stringify({ + model: 'deepseek-chat', + messages, + tools, + tool_choice: forceFinal ? {type: 'function', function: {name: 'final_answer'}} : 'auto', + temperature: args.temperature ?? 0.4, + max_tokens: args.maxTokens ?? 600, + }) + let res: Response + try { + res = await fetch(DEEPSEEK_URL, { + method: 'POST', + headers: {'content-type': 'application/json', authorization: `Bearer ${apiKey}`}, + body, + }) + } catch (err) { + args.audit?.trace({ + ts: new Date().toISOString(), + level: 'error', + event: 'mastra_network_error', + data: {message: err instanceof Error ? err.message : String(err)}, + }) + break + } + const latencyMs = Date.now() - t0 + if (!res.ok) { + const text = await res.text().catch(() => '') + args.audit?.trace({ + ts: new Date().toISOString(), + level: 'error', + event: 'mastra_http_error', + data: {status: res.status, body: text.slice(0, 300), latencyMs}, + }) + break + } + const json = (await res.json()) as { + choices?: Array<{message?: Message; finish_reason?: string}> + usage?: {prompt_tokens?: number; completion_tokens?: number; total_tokens?: number} + } + const choice = json.choices?.[0] + const message = choice?.message + if (!message) break + + args.audit?.llm({ + ts_start: new Date(t0).toISOString(), + ts_end: new Date().toISOString(), + latency_ms: latencyMs, + model: 'deepseek-chat', + completion: message.content ?? '', + tool_calls: message.tool_calls, + usage: { + prompt: json.usage?.prompt_tokens, + completion: json.usage?.completion_tokens, + total: json.usage?.total_tokens, + }, + }) + + messages.push(message) + + if (!message.tool_calls || message.tool_calls.length === 0) { + // Plain assistant text without tool call. Treat as final answer. + finalAnswer = (message.content ?? '').trim() || null + break + } + + let answeredViaFinal = false + for (const call of message.tool_calls) { + toolCallCount++ + const tool = allTools.find((t) => t.name === call.function.name) + if (!tool) { + messages.push({ + role: 'tool', + tool_call_id: call.id, + name: call.function.name, + content: `error: unknown tool ${call.function.name}`, + }) + continue + } + let parsed: any = {} + try { + parsed = call.function.arguments ? JSON.parse(call.function.arguments) : {} + } catch { + // pass through to handler with empty args + } + if (tool.name === 'final_answer') { + finalAnswer = String(parsed.body ?? '').trim() || null + answeredViaFinal = true + messages.push({ + role: 'tool', + tool_call_id: call.id, + name: 'final_answer', + content: 'ok', + }) + continue + } + const t1 = Date.now() + let result = '' + try { + result = await tool.handler(parsed) + } catch (err) { + result = `error: ${err instanceof Error ? err.message : String(err)}` + } + const latency = Date.now() - t1 + args.audit?.tool({ + ts_start: new Date(t1).toISOString(), + ts_end: new Date().toISOString(), + latency_ms: latency, + tool: tool.name, + args: parsed, + result: result.slice(0, 200), + }) + messages.push({ + role: 'tool', + tool_call_id: call.id, + name: tool.name, + content: result, + }) + } + + if (answeredViaFinal) break + if (toolCallCount >= MAX_TOOL_CALLS) { + // Force a terminal step on the next loop iteration (tool_choice locked + // to final_answer above). + continue + } + } + + // Fallback: model exhausted its budget without ever calling final_answer. + // Salvage whatever inline assistant text it produced during the loop — + // those interim "Let me search…" musings sometimes already contain enough + // synthesis to be useful. Prefer the LAST non-empty assistant text. + if (!finalAnswer) { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim().length > 40) { + finalAnswer = m.content.trim() + args.audit?.trace({ + ts: new Date().toISOString(), + level: 'warn', + event: 'mastra_agent_salvaged_inline_content', + data: {bytes: finalAnswer.length}, + }) + break + } + } + } + + args.audit?.trace({ + ts: new Date().toISOString(), + level: 'info', + event: 'mastra_agent_done', + data: {toolCallCount, finalAnswerBytes: finalAnswer?.length ?? 0}, + }) + + return {finalAnswer, history: messages, toolCallCount} +} + +/** + * Drives a community reply for an incoming mention. Used by poll-driver in + * Workstream B/C integration. Returns the final body or null on failure. + */ +export async function runMastraReply(opts: { + question: string + context: string + mention: Mention + cli: SeedCli + audit?: AuditRun +}): Promise { + const tools = buildAgentTools({cli: opts.cli, audit: opts.audit}) + const userMessage = + `Question (asked in comment ${opts.mention.commentId} on doc ${opts.mention.docId}):\n` + + `${opts.question}\n\n` + + (opts.context + ? `Pre-fetched context (use the tools above to drill deeper if needed):\n${opts.context}` + : `No pre-fetched context. Use the tools to gather what you need.`) + + const result = await runAgent({ + systemPrompt: COMMUNITY_AGENT_SYSTEM, + userMessage, + tools, + audit: opts.audit, + maxTokens: 500, + temperature: 0.4, + }) + return result.finalAnswer +} + +/** + * Operator-facing multi-turn chat. Caller persists `history` per chat id. + */ +export async function runMastraOperator(opts: { + question: string + systemContextBlob: string + history?: Message[] + cli: SeedCli + audit?: AuditRun +}): Promise<{finalAnswer: string | null; history: Message[]}> { + const tools = buildAgentTools({cli: opts.cli, audit: opts.audit}) + const userMessage = `Operator question: ${opts.question}\n\n## System context\n${opts.systemContextBlob}` + const result = await runAgent({ + systemPrompt: OPERATOR_AGENT_SYSTEM, + userMessage, + history: opts.history, + tools, + audit: opts.audit, + maxTokens: 800, + temperature: 0.2, + }) + return {finalAnswer: result.finalAnswer, history: result.history} +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts new file mode 100644 index 000000000..0b244f145 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/prompts.ts @@ -0,0 +1,45 @@ +/** + * System prompts for the Mastra agent loop. Kept out of the agent file so + * they can be edited / reviewed without scrolling past Connect/Mastra glue. + */ + +import {SEED_MARKDOWN_PRIMER} from '../seed-primer.js' + +export const COMMUNITY_AGENT_SYSTEM = `${SEED_MARKDOWN_PRIMER} + +You are the Knowledge Manager — a moderator of a Seed Hypermedia community. + +You answer questions from members in plain Spanish or English (match the asker's language). You ground every claim in the community's own documents and only fall back to general knowledge when the corpus is silent on the question. + +You have access to the following tools and you MUST use them when relevant: + - seed_search: keyword-search the community corpus. + - seed_get_doc: fetch the full body of an hm:// document. + - seed_get_comment_thread: fetch the parent thread (root + all replies) for a comment. + - seed_get_account_profile: fetch a profile / account doc. + - final_answer: produce the final reply to the user. You MUST call this exactly once when ready. + +Rules: + - Call seed_search at most once before answering. Then call seed_get_doc on AT MOST 2 citations you intend to embed. Do not refine the search; the first result set is what you have. + - Pull seed_get_comment_thread ONCE only if the question is in a reply chain. + - When the asker's message is just a mention chip with no actual question (text is empty or whitespace), respond briefly asking them to include the question. + - As soon as you have enough material, call final_answer. Aim for 3-5 tool calls total, never more than 8. + - When citing, embed full hm:// URLs as inline markdown links: [Title](hm://...) (NEVER a bare hm:// URL). + - When referencing a person, use the mention chip syntax: . + - Stay under 120 words in the final answer. Plain text or simple markdown only — no headers, no code fences, no greeting/signoff. + - HARD tool budget: 10 tool calls per turn. After 8 you MUST call final_answer immediately — even if uncertain, summarise what you have and say so. + - If the corpus is silent, answer from general knowledge in one sentence and explicitly say: "I couldn't find this in our community's docs".` + +export const OPERATOR_AGENT_SYSTEM = `You are the Knowledge Manager bot answering an OPERATOR question about your own implementation, configuration, and recent activity. + +You have access to the following tools: + - seed_search: search the community corpus (only when the operator asks about community content). + - seed_get_doc: fetch a doc body. + - km_recent_runs: read the last N audit runs from disk. + - km_show_rules: read the live agent-rules YAML block. + - km_status: summary of timers, last-run times, and ready_for_writes. + - final_answer: produce the final reply. You MUST call this exactly once. + +Rules: + - Use the system tools (km_*) when the question is about the agent itself. + - Never make up paths, services, commands, or run ids. Only echo strings you read via tools. + - Stay under 200 words. Plain text or simple markdown.` diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/tools-bridge.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/tools-bridge.ts new file mode 100644 index 000000000..39385c958 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/agent/tools-bridge.ts @@ -0,0 +1,131 @@ +/** + * Tool registry for the Mastra agent loop. Exposes a small JSON-Schema-typed + * surface that maps directly to seed-cli subprocess calls. Mirrors the MCP + * tool registry in `tools.ts` but bypasses MCP for in-process use. + * + * Each tool's `handler` returns a string that goes back to the LLM. Large + * responses are truncated to keep the context window usable. + */ + +import type {SeedCli} from '../seedcli.js' +import type {AuditRun} from '../audit.js' + +export type ToolDef = { + name: string + description: string + parameters: { + type: 'object' + properties: Record + required?: string[] + } + handler: (args: any) => Promise +} + +const MAX_DOC_CHARS = 4_000 +const MAX_THREAD_COMMENTS = 30 + +export function buildAgentTools(opts: {cli: SeedCli; audit?: AuditRun}): ToolDef[] { + const {cli} = opts + + return [ + { + name: 'seed_search', + description: 'Keyword-search the community corpus. Returns a list of hm:// URLs and titles.', + parameters: { + type: 'object', + properties: { + query: {type: 'string', description: 'Free-text search query'}, + limit: {type: 'number', description: 'Maximum hits to return (default 5, max 10)'}, + }, + required: ['query'], + }, + handler: async (args) => { + const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 10) + const r = await cli.runRead(['search', String(args.query), '--limit', String(limit)]) + if (r.exitCode !== 0) return `error: ${r.stderr.slice(0, 200)}` + const parsed = r.parsedJson as {entities?: any[]; results?: any[]} | undefined + const hits = parsed?.entities ?? parsed?.results ?? [] + const lines = hits.slice(0, limit).map((h: any) => { + const id = typeof h.id === 'string' ? h.id : h.id?.id + return `${id} — ${h.title ?? '(untitled)'}` + }) + return lines.length > 0 ? lines.join('\n') : '(no results)' + }, + }, + { + name: 'seed_get_doc', + description: 'Fetch the full body of an hm:// document. Returns markdown.', + parameters: { + type: 'object', + properties: { + hm_url: {type: 'string', description: 'hm:// URL of the document'}, + }, + required: ['hm_url'], + }, + handler: async (args) => { + const r = await cli.runRead(['document', 'get', String(args.hm_url)]) + if (r.exitCode !== 0) return `error: ${r.stderr.slice(0, 200)}` + const body = r.stdout.replace(//g, '').trim() + return body.length > MAX_DOC_CHARS ? body.slice(0, MAX_DOC_CHARS) + '\n…(truncated)' : body + }, + }, + { + name: 'seed_get_comment_thread', + description: 'Fetch the comment thread (root + replies) for a given comment id.', + parameters: { + type: 'object', + properties: { + comment_id: {type: 'string', description: 'Canonical comment id (author/tsid)'}, + max: {type: 'number', description: 'Max comments (default 30)'}, + }, + required: ['comment_id'], + }, + handler: async (args) => { + const max = Math.min(Math.max(Number(args.max) || MAX_THREAD_COMMENTS, 1), 100) + // Walk replyParent up to root. + const collected: any[] = [] + let current = String(args.comment_id) + for (let i = 0; i < max; i++) { + const r = await cli.runRead(['comment', 'get', current]) + if (r.exitCode !== 0) break + const c = r.parsedJson as any + if (!c) break + collected.unshift(c) + if (!c.replyParent) break + current = c.replyParent + } + if (collected.length === 0) return '(thread not found)' + return collected + .map((c, i) => `(#${i + 1}) ${c.author}\n${stringifyComment(c)}`) + .join('\n\n') + }, + }, + { + name: 'seed_get_account_profile', + description: 'Fetch the profile metadata for a Seed account.', + parameters: { + type: 'object', + properties: { + account_id: {type: 'string', description: 'Account uid (z6Mk...)'}, + }, + required: ['account_id'], + }, + handler: async (args) => { + const r = await cli.runRead(['account', 'get', String(args.account_id)]) + if (r.exitCode !== 0) return `error: ${r.stderr.slice(0, 200)}` + return JSON.stringify(r.parsedJson ?? {}, null, 2).slice(0, MAX_DOC_CHARS) + }, + }, + ] +} + +function stringifyComment(c: any): string { + if (typeof c.body === 'string') return c.body + if (Array.isArray(c.content)) { + return c.content + .map((b: any) => b?.block?.text ?? b?.text ?? '') + .filter(Boolean) + .join('\n') + } + return '' +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/audit.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/audit.ts new file mode 100644 index 000000000..cd2748171 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/audit.ts @@ -0,0 +1,217 @@ +/** + * Per-run audit log. Each agent invocation gets its own directory under + * `${logsDir}/runs/____/` with a stable layout: + * + * meta.json — trigger, KM_AID, env hash, start/end, wall_ms + * trace.jsonl — ordered events with timestamps + * llm.jsonl — prompts, completions, reasoning, token usage + * tools.jsonl — MCP tool calls + latency + * seed-cli.jsonl — argv + stdout + stderr + exit + ms + * + * A `current` symlink in `${logsDir}` always points at the newest run for + * easy `tail -F current/trace.jsonl`. A top-level `index.jsonl` carries one + * summary line per run for `km-log` browsing. + * + * All writes are append-only with `O_APPEND` and flushed eagerly so a crash + * never loses the last record. All values pass through the Redactor before + * being serialised so the persisted log can never contain a known secret. + */ + +import {appendFileSync, closeSync, existsSync, mkdirSync, openSync, statSync, symlinkSync, unlinkSync, writeFileSync} from 'node:fs' +import {join} from 'node:path' +import {ulid} from 'ulid' +import type {Redactor} from './redact.js' +import {createObservabilityClientFromEnv, type ObservabilityClient, type TelemetryKind} from './observability.js' + +export type Trigger = string + +export type AuditMeta = { + runId: string + trigger: Trigger + startedAt: string + endedAt?: string + wallMs?: number + status?: 'ok' | 'error' | 'denied' + kmAccountId?: string + seedSite?: string + counters?: Record +} + +export type TraceEvent = { + ts: string + level: 'debug' | 'info' | 'warn' | 'error' + event: string + data?: unknown +} + +export type ToolCallRecord = { + ts_start: string + ts_end: string + latency_ms: number + tool: string + args: unknown + result?: unknown + error?: string +} + +export type SeedCliRecord = { + ts_start: string + ts_end: string + latency_ms: number + argv: string[] + exit_code: number + stdout?: string + stderr?: string +} + +export type LlmRecord = { + ts_start: string + ts_end: string + latency_ms: number + model?: string + prompt_messages?: unknown + completion?: string + reasoning?: string + tool_calls?: unknown + usage?: {prompt?: number; completion?: number; total?: number} +} + +export class AuditRun { + readonly meta: AuditMeta + readonly dir: string + private readonly redactor: Redactor + private readonly observability: ObservabilityClient | null + private closed = false + private startTime: number + + constructor(opts: {logsDir: string; trigger: Trigger; redactor: Redactor; kmAccountId?: string; seedSite?: string}) { + this.redactor = opts.redactor + this.observability = createObservabilityClientFromEnv(opts.redactor) + const runId = ulid() + const now = new Date() + this.startTime = now.getTime() + const isoSlug = now.toISOString().replace(/[:]/g, '-').replace(/\..+$/, 'Z') + const slug = `${isoSlug}__${sanitize(opts.trigger)}__${runId}` + this.dir = join(opts.logsDir, 'runs', slug) + mkdirSync(this.dir, {recursive: true, mode: 0o700}) + this.meta = { + runId, + trigger: opts.trigger, + startedAt: now.toISOString(), + kmAccountId: opts.kmAccountId, + seedSite: opts.seedSite, + counters: {}, + } + this.flushMeta() + this.emitTelemetry('run_meta', this.meta) + this.updateCurrent(opts.logsDir, slug) + } + + trace(event: TraceEvent): void { + this.appendJsonl('trace.jsonl', event) + this.emitTelemetry('trace', event) + } + + tool(record: ToolCallRecord): void { + this.appendJsonl('tools.jsonl', record) + this.emitTelemetry('tool', record) + this.bumpCounter('tool_calls') + } + + llm(record: LlmRecord): void { + this.appendJsonl('llm.jsonl', record) + this.emitTelemetry('llm', record) + this.bumpCounter('llm_calls') + } + + seedCli(record: SeedCliRecord): void { + this.appendJsonl('seed-cli.jsonl', record) + this.emitTelemetry('seed_cli', record) + this.bumpCounter('seed_cli_calls') + } + + telemetry(kind: TelemetryKind, data: unknown): void { + this.emitTelemetry(kind, data) + } + + async flushTelemetry(timeoutMs = 1_500): Promise { + await this.observability?.flush(timeoutMs) + } + + bumpCounter(name: string, delta = 1): void { + if (!this.meta.counters) this.meta.counters = {} + this.meta.counters[name] = (this.meta.counters[name] ?? 0) + delta + } + + close(opts: {status?: 'ok' | 'error' | 'denied'; logsDir: string}): void { + if (this.closed) return + this.closed = true + const now = new Date() + this.meta.endedAt = now.toISOString() + this.meta.wallMs = now.getTime() - this.startTime + this.meta.status = opts.status ?? 'ok' + this.flushMeta() + this.emitTelemetry('run_meta', this.meta) + appendIndex(opts.logsDir, this.meta) + } + + private emitTelemetry(kind: TelemetryKind, data: unknown): void { + this.observability?.emit(kind, this.meta.runId, data) + } + + private appendJsonl(file: string, value: unknown): void { + const line = this.redactor(JSON.stringify(value)) + '\n' + const path = join(this.dir, file) + const fd = openSync(path, 'a') + try { + appendFileSync(fd, line) + } finally { + // openSync + appendFileSync(fd) doesn't auto-close; release the fd. + closeSync(fd) + } + } + + private flushMeta(): void { + const path = join(this.dir, 'meta.json') + writeFileSync(path, this.redactor(JSON.stringify(this.meta, null, 2)) + '\n', {mode: 0o600}) + } + + private updateCurrent(logsDir: string, slug: string): void { + const link = join(logsDir, 'current') + try { + if (existsSync(link)) unlinkSync(link) + } catch { + /* ignore */ + } + try { + symlinkSync(join('runs', slug), link) + } catch { + /* logsDir may be on a fs without symlink support; non-fatal */ + } + } +} + +function appendIndex(logsDir: string, meta: AuditMeta): void { + const indexPath = join(logsDir, 'index.jsonl') + if (!existsSync(logsDir)) mkdirSync(logsDir, {recursive: true, mode: 0o700}) + const line = + JSON.stringify({ + id: meta.runId, + trigger: meta.trigger, + start: meta.startedAt, + end: meta.endedAt, + status: meta.status, + wall_ms: meta.wallMs, + counters: meta.counters, + }) + '\n' + appendFileSync(indexPath, line) + try { + statSync(indexPath) + } catch { + /* ignore */ + } +} + +function sanitize(s: string): string { + return s.replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 64) +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/cadence-cli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/cadence-cli.ts new file mode 100644 index 000000000..b4c29e59e --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/cadence-cli.ts @@ -0,0 +1,562 @@ +#!/usr/bin/env node +/** + * Standalone driver for the LAFH cadenced outputs: + * - boletin: weekly bulletin + * - gap: gap-detection report + * - health: network-health report + * + * Pattern: load governance → collect a period snapshot from activity → + * one DeepSeek call to draft the doc body → publish via seed-cli at a + * deterministic path under `/agents/knowledge-manager/state/...`. + * + * No nanobot. No tool orchestration. systemd timers invoke this with + * `KM_TASK=`. + * + * Templates referenced inline are the human-canonical structure (see + * seed-knowledge-manager/templates/) — we feed a compact version into + * the system prompt so DeepSeek produces consistent output. + */ + +import {GovernanceCache} from './governance.js' +import {SeedCli} from './seedcli.js' +import {AuditRun} from './audit.js' +import {buildRedactor} from './redact.js' +import {loadConfig} from './config.js' +import {bump, checkCap, isWriteAllowed} from './limits.js' +import {State} from './state.js' +import {SEED_MARKDOWN_PRIMER} from './seed-primer.js' + +type Task = 'boletin' | 'gap' | 'health' + +type TaskConfig = { + task: Task + /** ISO date period stamp used as path slug. */ + periodStamp: string + /** Human-readable period for prose (e.g. "2026-W19", "April 2026"). */ + periodLabel: string + /** Window of activity to summarize, in days. */ + windowDays: number + /** Path under the site root where the doc is created (relative to /). */ + docPath: string + /** Doc title. */ + title: string + /** Frontmatter `type` value. */ + type: string + /** System-prompt skeleton + instructions. */ + systemPrompt: string +} + +async function main(): Promise { + const taskName = (process.env.KM_TASK ?? '').toLowerCase() + if (!isTask(taskName)) { + throw new Error(`KM_TASK must be one of: boletin | gap | health (got "${taskName}")`) + } + const config = loadConfig() + const redactor = buildRedactor() + const audit = new AuditRun({ + logsDir: config.logsDir, + trigger: `cadence-${taskName}`, + redactor, + seedSite: config.seedSite, + }) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'agent_start', + data: {seedServer: config.seedServer, seedSite: config.seedSite, mode: 'cadence-cli', task: taskName}, + }) + + let status: 'ok' | 'error' | 'denied' = 'ok' + try { + const cli = new SeedCli(config, redactor, audit) + const state = new State(config.stateDir) + const governance = new GovernanceCache(config, cli) + + // Resolve agent accountId. + const keyShow = await cli.runRead(['key', 'show', config.keyName]) + if (keyShow.exitCode !== 0) throw new Error(`key show failed: ${keyShow.stderr}`) + const kmAccountId = (keyShow.parsedJson as {accountId?: string} | undefined)?.accountId + if (!kmAccountId) throw new Error('Could not resolve agent accountId') + audit.meta.kmAccountId = kmAccountId + + const g = await governance.getGovernance(true) + audit.trace({ts: nowIso(), level: 'info', event: 'governance_loaded', data: {fetchedAt: g.fetchedAt}}) + + const tc = buildTaskConfig(taskName, g.runbook, g.charter) + + // Path policy: documents are writes; respect rules. + if (g.rules.draftOnly) { + audit.trace({ts: nowIso(), level: 'warn', event: 'draft_only_active', data: {task: tc.task, path: tc.docPath}}) + status = 'denied' + return + } + const allow = isWriteAllowed(tc.docPath, g.rules) + if (!allow.allowed) { + audit.trace({ts: nowIso(), level: 'warn', event: 'write_blocked_by_rules', data: {path: tc.docPath, reason: allow.reason}}) + status = 'denied' + return + } + const cap = checkCap(state.getRateState(), 'documents', g.rules) + if (!cap.allowed) { + audit.trace({ts: nowIso(), level: 'warn', event: 'write_blocked_by_rules', data: {path: tc.docPath, reason: cap.reason}}) + status = 'denied' + return + } + + // Collect activity snapshot. + const cutoffMs = Date.now() - tc.windowDays * 86_400_000 + const snapshot = await collectSnapshot(cli, config.seedSite, cutoffMs) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'snapshot_collected', + data: { + windowDays: tc.windowDays, + events: snapshot.events.length, + comments: snapshot.commentsByDoc.size, + docs: snapshot.docsByPath.size, + authors: snapshot.activeAuthors.size, + }, + }) + + // Draft the body via DeepSeek. + const userPrompt = buildUserPrompt(tc, snapshot) + const draft = await draftDoc(tc.systemPrompt, userPrompt, audit) + if (!draft) throw new Error('DeepSeek returned no completion') + + // Publish. + const fullDoc = ensureFrontmatter(draft, { + title: tc.title, + type: tc.type, + period: tc.periodStamp, + periodLabel: tc.periodLabel, + created_by: 'knowledge-manager', + created_at: new Date().toISOString(), + }) + const tmpFile = await writeTempMarkdown(fullDoc) + const siteAccount = config.seedSite.replace(/^hm:\/\//, '').split('/')[0]! + + // Ensure parent index docs exist along the path. Without them the desktop + // navigator cannot drill into /agents/knowledge-manager/state/. + await ensureParentIndexes(cli, siteAccount, tc.docPath, audit) + + const argv = [ + 'document', + 'create', + '--account', + siteAccount, + '--path', + tc.docPath, + '--name', + tc.title, + '--file', + tmpFile, + '--force', // each cadence run replaces the doc at the same path + ] + const r = await cli.runWrite(argv) + if (r.exitCode === 0) { + state.setRateState(bump(state.getRateState(), 'documents')) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'cadence_doc_published', + data: {task: tc.task, path: tc.docPath, link: extractLink(r.stdout)}, + }) + } else { + status = 'error' + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'cadence_doc_failed', + data: {task: tc.task, exitCode: r.exitCode, stderr: r.stderr.slice(0, 400)}, + }) + } + } catch (err) { + status = 'error' + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'cadence_fatal', + data: {message: err instanceof Error ? err.message : String(err)}, + }) + } finally { + audit.trace({ts: nowIso(), level: 'info', event: 'agent_end', data: {status}}) + audit.close({status, logsDir: config.logsDir}) + } +} + +function isTask(s: string): s is Task { + return s === 'boletin' || s === 'gap' || s === 'health' +} + +/** + * Walks the parent path segments of `docPath` and creates a minimal index + * document at any segment that does not yet exist. Idempotent — uses + * `document get` first; only calls `document create` on a not-found. + * + * Without these index docs the desktop navigator shows "not-found" when a + * user clicks into /agents/knowledge-manager/state/ even though leaf docs + * exist under that prefix. + */ +async function ensureParentIndexes( + cli: SeedCli, + siteAccount: string, + docPath: string, + audit: AuditRun, +): Promise { + const segments = docPath.split('/').filter(Boolean) + if (segments.length <= 1) return + // We only seed parents — not the leaf doc itself, which the cadence writes. + for (let i = 1; i < segments.length; i++) { + const parentPath = '/' + segments.slice(0, i).join('/') + const hmUrl = `hm://${siteAccount}${parentPath}` + const getR = await cli.runRead(['document', 'get', hmUrl]) + if (getR.exitCode === 0 && getR.stdout && !/not-found|Cannot render/i.test(getR.stdout)) { + continue + } + const title = segments[i - 1]! + .split('-') + .map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : '')) + .join(' ') + const body = + `---\n` + + `title: ${title}\n` + + `type: index\n` + + `created_by: knowledge-manager\n` + + `created_at: ${new Date().toISOString()}\n` + + `---\n\n` + + `## ${title}\n\n` + + `Index of \`${parentPath}\`. Child documents are listed automatically by the navigator.\n` + const tmp = await writeTempMarkdown(body) + const createArgv = [ + 'document', + 'create', + '--account', + siteAccount, + '--path', + parentPath, + '--name', + title, + '--file', + tmp, + ] + const cR = await cli.runWrite(createArgv) + audit.trace({ + ts: nowIso(), + level: cR.exitCode === 0 ? 'info' : 'warn', + event: cR.exitCode === 0 ? 'parent_index_created' : 'parent_index_create_failed', + data: {path: parentPath, exitCode: cR.exitCode, stderr: cR.stderr.slice(0, 200)}, + }) + } +} + +function buildTaskConfig(task: Task, runbook: string, charter: string): TaskConfig { + const now = new Date() + const lafhRunbookContext = + `Charter excerpt:\n${charter.slice(0, 1200)}\n\nRunbook excerpt:\n${runbook.slice(0, 1200)}` + if (task === 'boletin') { + const week = isoWeekStamp(now) + return { + task, + periodStamp: week, + periodLabel: week, + windowDays: 7, + docPath: `/agents/knowledge-manager/state/boletin/${week}`, + title: `Boletín — ${week}`, + type: 'boletin', + systemPrompt: + `${SEED_MARKDOWN_PRIMER}\n\n` + + `You are the Knowledge Manager generating the WEEKLY BULLETIN (boletín periódico) for a Seed Hypermedia community, applying LAFH/GC-Red methodology.\n\n` + + `Output a complete Markdown document body. Use these section headings exactly, in this order:\n` + + `## New documents published\n## Active threads\n## Decisions made\n## New members\n## Gaps surfaced or filled\n## Recommended reading from this period\n## Health note\n\n` + + `Format rules:\n` + + `- For "Recommended reading", use embed chips on their own lines: \`\`. One per line.\n` + + `- For all other sections, use inline links \`[Title](hm://...)\` to cite docs/comments.\n` + + `- Lists go directly under the heading — no intro sentence between the heading and the first bullet.\n` + + `- Cap each list at 5–7 items, prioritized (not exhaustive). Be concise — scannable in two minutes.\n\n` + + `Where the snapshot lacks data for a section, write one honest sentence ("no formal decisions captured this period" etc) — that is itself a signal. Do NOT invent items.\n\n` + + `${lafhRunbookContext}`, + } + } + if (task === 'gap') { + const stamp = isoDateStamp(now) + return { + task, + periodStamp: stamp, + periodLabel: stamp, + windowDays: 7, + docPath: `/agents/knowledge-manager/state/gaps/${stamp}`, + title: `Gap report — ${stamp}`, + type: 'gap-report', + systemPrompt: + `${SEED_MARKDOWN_PRIMER}\n\n` + + `You are the Knowledge Manager generating a GAP REPORT for a Seed Hypermedia community, applying LAFH/GC-Red methodology.\n\n` + + `Output a complete Markdown document body. Use these sections in this order:\n` + + `## How this was produced\n## Open gaps\n### 🔴 High priority\n### 🟡 Medium priority\n### 🟢 Low priority / parking lot\n## Contradictions detected\n## Stale or potentially outdated content\n## Patterns\n\n` + + `For each gap, write the following block (no extra wrapping paragraph):\n` + + `- **Title** — short summary of the gap\n` + + `- **Evidence:** \`[doc/thread title](hm://...)\` references\n` + + `- **Why it matters:** one sentence\n` + + `- **Proposed action:** one sentence\n` + + `- **Suggested owner:** mention chip \`>\` or "open"\n\n` + + `Lists go directly under headings. Do NOT invent gaps; if the snapshot doesn't surface enough data, write "no high-priority gaps detected this period" and move on. Honest signal beats fluff.\n\n` + + `${lafhRunbookContext}`, + } + } + // health + const stamp = isoMonthStamp(now) + return { + task, + periodStamp: stamp, + periodLabel: stamp, + windowDays: 30, + docPath: `/agents/knowledge-manager/state/network-health/${stamp}`, + title: `Network health — ${stamp}`, + type: 'network-health', + systemPrompt: + `${SEED_MARKDOWN_PRIMER}\n\n` + + `You are the Knowledge Manager generating a NETWORK HEALTH REPORT for a Seed Hypermedia community, applying LAFH/GC-Red methodology.\n\n` + + `Output a complete Markdown document body. Sections in this order:\n` + + `## TL;DR\n## Activity metrics\n## Production of knowledge products\n## Silos\n## Stale corpus\n## Pace assessment\n## Memory check\n## Methodology adherence\n## Recommended actions\n\n` + + `Format rules:\n` + + `- Inline links: \`[Title](hm://...)\`. No bare hm:// URLs in prose.\n` + + `- Lists go directly under headings — no leading intro paragraph between heading and first bullet.\n` + + `- Quantify when you can ("N new docs", "M comments", "K active authors of N total writers").\n\n` + + `Be diagnostic, not flattering. Activity without production = noise (LAFH red flag).\n\n` + + `${lafhRunbookContext}`, + } +} + +// ─── Snapshot collection ───────────────────────────────────────────────── + +type Snapshot = { + events: ActivityEvent[] + /** docId -> count */ + commentsByDoc: Map + /** docId -> latest update time */ + docsByPath: Map + /** authorId -> count */ + activeAuthors: Map +} + +type ActivityEvent = { + id?: string + type?: string + time?: string + author?: {id?: {uid?: string}} + // The daemon serializes ID fields inconsistently — sometimes a bare + // string, sometimes an object with id/uid/path/version. We tolerate + // both via unwrapTargetId. + docId?: unknown + target?: unknown + capability?: unknown +} + +async function collectSnapshot(cli: SeedCli, seedSite: string, cutoffMs: number): Promise { + const r = await cli.runRead(['activity', '--limit', '300']) + const all = ((r.parsedJson as {events?: ActivityEvent[]} | undefined)?.events) ?? [] + const siteAccount = seedSite.replace(/^hm:\/\//, '').split('/')[0]! + const events: ActivityEvent[] = [] + const commentsByDoc = new Map() + const docsByPath = new Map() + const activeAuthors = new Map() + for (const ev of all) { + if (!ev.time) continue + const t = Date.parse(ev.time) + if (Number.isFinite(t) && t < cutoffMs) continue + // Filter to events touching our site. + const target = unwrapTargetId(ev) ?? '' + if (siteAccount && !target.includes(siteAccount)) continue + events.push(ev) + const author = ev.author?.id?.uid + if (author) activeAuthors.set(author, (activeAuthors.get(author) ?? 0) + 1) + if (ev.type === 'comment') { + const docId = stripFragment(target) + commentsByDoc.set(docId, (commentsByDoc.get(docId) ?? 0) + 1) + } + if (ev.type === 'doc-update') { + const docIdStr = unwrapTargetId(ev) + if (docIdStr) docsByPath.set(stripVersion(docIdStr), ev.time ?? '') + } + } + return {events, commentsByDoc, docsByPath, activeAuthors} +} + +function unwrapTargetId(ev: ActivityEvent): string | undefined { + // Daemon serializes IDs as either a bare string OR an object + // {id?, uid?, ...}. Tolerate both shapes (and refuse anything else). + const tryId = (v: unknown): string | undefined => { + if (typeof v === 'string') return v + if (v && typeof v === 'object') { + const o = v as {id?: unknown; uid?: unknown} + if (typeof o.id === 'string') return o.id + if (typeof o.uid === 'string') return `hm://${o.uid}` + } + return undefined + } + return tryId(ev.docId) ?? tryId(ev.target) +} + +function stripFragment(id: string): string { + return id.split('#')[0]! +} + +function stripVersion(id: string): string { + return id.split('?')[0]!.split('#')[0]! +} + +// ─── Prompt builder ────────────────────────────────────────────────────── + +function buildUserPrompt(tc: TaskConfig, snapshot: Snapshot): string { + const lines: string[] = [] + lines.push(`Task: ${tc.task}`) + lines.push(`Period: ${tc.periodLabel} (window: last ${tc.windowDays} days)`) + lines.push(`Site activity snapshot below.`) + lines.push('') + lines.push(`### New / updated documents (${snapshot.docsByPath.size})`) + for (const [doc, time] of snapshot.docsByPath) { + lines.push(`- ${doc} (last update ${time})`) + } + lines.push('') + lines.push(`### Comment activity by document (${snapshot.commentsByDoc.size} docs)`) + for (const [doc, count] of snapshot.commentsByDoc) { + lines.push(`- ${doc}: ${count} comments`) + } + lines.push('') + lines.push(`### Active authors (${snapshot.activeAuthors.size})`) + for (const [author, count] of snapshot.activeAuthors) { + lines.push(`- ${author}: ${count} events`) + } + lines.push('') + lines.push('### Raw events (most recent first, truncated)') + const sample = snapshot.events.slice(0, 80) + for (const ev of sample) { + const author = ev.author?.id?.uid ?? '?' + const target = unwrapTargetId(ev) ?? '?' + lines.push(`- ${ev.time} ${ev.type} by ${author} → ${target}`) + } + lines.push('') + lines.push(`Produce the document now. Stick to the section headings in the system prompt. Use plain markdown. Do not wrap in code fences. Do not add a YAML frontmatter — that will be inserted automatically.`) + return lines.join('\n') +} + +async function draftDoc(systemPrompt: string, userPrompt: string, audit: AuditRun): Promise { + const apiKey = process.env.DEEPSEEK_API_KEY + if (!apiKey) { + audit.trace({ts: nowIso(), level: 'error', event: 'deepseek_no_key'}) + return null + } + const body = JSON.stringify({ + model: 'deepseek-chat', + messages: [ + {role: 'system', content: systemPrompt}, + {role: 'user', content: userPrompt}, + ], + temperature: 0.3, + max_tokens: 2400, + }) + const t0 = Date.now() + let res: Response + try { + res = await fetch('https://api.deepseek.com/v1/chat/completions', { + method: 'POST', + headers: {'content-type': 'application/json', authorization: `Bearer ${apiKey}`}, + body, + }) + } catch (err) { + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'deepseek_network_error', + data: {message: err instanceof Error ? err.message : String(err)}, + }) + return null + } + const latencyMs = Date.now() - t0 + if (!res.ok) { + const text = await res.text().catch(() => '') + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'deepseek_http_error', + data: {status: res.status, body: text.slice(0, 400), latencyMs}, + }) + return null + } + const json = (await res.json()) as { + choices?: Array<{message?: {content?: string}}> + usage?: {prompt_tokens?: number; completion_tokens?: number; total_tokens?: number} + } + const completion = json.choices?.[0]?.message?.content?.trim() + audit.llm({ + ts_start: new Date(t0).toISOString(), + ts_end: nowIso(), + latency_ms: latencyMs, + model: 'deepseek-chat', + completion: completion ?? '', + usage: { + prompt: json.usage?.prompt_tokens, + completion: json.usage?.completion_tokens, + total: json.usage?.total_tokens, + }, + }) + return completion ?? null +} + +// ─── Output assembly ───────────────────────────────────────────────────── + +function ensureFrontmatter(body: string, fm: Record): string { + if (body.startsWith('---\n')) return body + const lines: string[] = ['---'] + for (const [k, v] of Object.entries(fm)) { + lines.push(`${k}: ${escapeYaml(v)}`) + } + lines.push('---', '') + return lines.join('\n') + '\n' + body +} + +function escapeYaml(value: string): string { + if (/^[a-zA-Z0-9_./-]+$/.test(value)) return value + return JSON.stringify(value) +} + +async function writeTempMarkdown(body: string): Promise { + const {writeFileSync} = await import('node:fs') + const {tmpdir} = await import('node:os') + const {join} = await import('node:path') + const path = join(tmpdir(), `km-cadence-${Date.now()}-${Math.random().toString(36).slice(2)}.md`) + writeFileSync(path, body, {mode: 0o600}) + return path +} + +function extractLink(stdout: string): string | undefined { + return stdout.match(/https?:\/\/\S+/)?.[0] +} + +function nowIso(): string { + return new Date().toISOString() +} + +function isoDateStamp(d: Date): string { + return d.toISOString().slice(0, 10) +} + +function isoMonthStamp(d: Date): string { + return d.toISOString().slice(0, 7) +} + +function isoWeekStamp(d: Date): string { + // ISO week per https://en.wikipedia.org/wiki/ISO_week_date + const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())) + const day = t.getUTCDay() || 7 + t.setUTCDate(t.getUTCDate() + 4 - day) + const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1)) + const weekNum = Math.ceil(((t.getTime() - yearStart.getTime()) / 86400000 + 1) / 7) + return `${t.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}` +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('km-cadence fatal:', err) + process.exit(1) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/chat-history.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/chat-history.ts new file mode 100644 index 000000000..f83f00f04 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/chat-history.ts @@ -0,0 +1,57 @@ +/** + * Per-Telegram-chat conversation history. Append-only JSONL files, one + * per chat-id, capped to the last N turns when read. Survives process + * restarts. Bounded so a runaway chat can't grow logs forever. + */ + +import {appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs' +import {join} from 'node:path' +import type {ChatTurn} from './reply-engine.js' + +const MAX_TURNS_RETURNED = 10 +const ROTATE_BYTES = 256 * 1024 // rotate file after 256KB + +export class ChatHistory { + private readonly dir: string + + constructor(stateDir: string) { + this.dir = join(stateDir, 'telegram-history') + if (!existsSync(this.dir)) mkdirSync(this.dir, {recursive: true, mode: 0o700}) + } + + /** Returns the last MAX_TURNS_RETURNED turns for the chat, oldest first. */ + read(chatId: number): ChatTurn[] { + const path = this.pathFor(chatId) + if (!existsSync(path)) return [] + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean) + const turns: ChatTurn[] = [] + for (const line of lines) { + try { + const t = JSON.parse(line) as ChatTurn + if ((t.role === 'user' || t.role === 'assistant') && typeof t.content === 'string') turns.push(t) + } catch { + /* skip */ + } + } + return turns.slice(-MAX_TURNS_RETURNED) + } + + append(chatId: number, turns: ChatTurn[]): void { + if (turns.length === 0) return + const path = this.pathFor(chatId) + // Rotate when the file gets large. + if (existsSync(path)) { + const stat = require('node:fs').statSync(path) as {size: number} + if (stat.size > ROTATE_BYTES) { + const recent = this.read(chatId) + writeFileSync(path, recent.map((t) => JSON.stringify(t)).join('\n') + '\n', {mode: 0o600}) + } + } + const lines = turns.map((t) => JSON.stringify(t)).join('\n') + '\n' + appendFileSync(path, lines) + } + + private pathFor(chatId: number): string { + return join(this.dir, `${chatId}.jsonl`) + } +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/config.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/config.ts new file mode 100644 index 000000000..7f5b1d54d --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/config.ts @@ -0,0 +1,62 @@ +/** + * Runtime configuration sourced from environment variables. The MCP server + * is launched by `nanobot gateway`, which forwards these via the `env` + * field of the `mcpServers` entry in `~/.nanobot/config.json`. + */ + +export type AgentConfig = { + seedServer: string + seedSite: string + keyName: string + cliPath: string + stateDir: string + logsDir: string + rulesTtlMs: number + writersTtlMs: number + governanceBasePath: string + /** When true, drivers refuse to start unless `seed-cli site sync-status` + * reports ready_for_writes. Set via KM_USE_LOCAL_DAEMON=1. */ + useLocalDaemon: boolean + /** Account id whose WRITER capability gates ready_for_writes. Defaults to + * KM_AID; if absent, falls back to "any writer cap present". */ + writerAid: string | null + /** When true, poll-cli replaces the ad-hoc two-pass loop with the XState + * supervisor (machines/supervisor.ts). Set via KM_USE_STATE_MACHINE=1. */ + useStateMachine: boolean + /** When true, finalisation calls the Mastra agent via agent/mastra-agent.ts + * instead of reply-engine.draftReply. Set via KM_USE_MASTRA_AGENT=1. */ + useMastraAgent: boolean +} + +const DEFAULT_RULES_TTL_MS = 60_000 +const DEFAULT_WRITERS_TTL_MS = 5 * 60_000 + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): AgentConfig { + const required = ['SEED_SERVER', 'SEED_SITE'] as const + for (const key of required) { + if (!env[key]) { + throw new Error(`Missing required env var: ${key}`) + } + } + return { + seedServer: env.SEED_SERVER!, + seedSite: env.SEED_SITE!, + keyName: env.KM_KEY_NAME ?? 'knowledge-manager', + cliPath: env.SEED_CLI_PATH ?? '/home/km/.local/bin/seed-cli', + stateDir: env.KM_STATE_DIR ?? '/home/km/km-state', + logsDir: env.KM_LOGS_DIR ?? '/home/km/km-logs', + rulesTtlMs: numberOr(env.KM_RULES_TTL_MS, DEFAULT_RULES_TTL_MS), + writersTtlMs: numberOr(env.KM_WRITERS_TTL_MS, DEFAULT_WRITERS_TTL_MS), + governanceBasePath: env.KM_GOVERNANCE_BASE_PATH ?? '/agents/knowledge-manager', + useLocalDaemon: env.KM_USE_LOCAL_DAEMON === '1' || env.KM_USE_LOCAL_DAEMON === 'true', + writerAid: env.KM_AID ?? null, + useStateMachine: env.KM_USE_STATE_MACHINE === '1' || env.KM_USE_STATE_MACHINE === 'true', + useMastraAgent: env.KM_USE_MASTRA_AGENT === '1' || env.KM_USE_MASTRA_AGENT === 'true', + } +} + +function numberOr(value: string | undefined, fallback: number): number { + if (!value) return fallback + const n = Number(value) + return Number.isFinite(n) && n > 0 ? n : fallback +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.test.ts new file mode 100644 index 000000000..cc3e97379 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.test.ts @@ -0,0 +1,89 @@ +import {describe, expect, it} from 'bun:test' +import {extractYamlBlock, parseAllowlistBody, parseRulesBody} from './governance.js' + +const RULES_DOC = `--- +type: agent-rules +schema_version: 1 +title: foo +--- + +# Rules + +The agent reads this on every run. + +\`\`\`yaml +allow_write_paths: + - / +deny_write_paths: + - /agents/knowledge-manager/charter +caps: + max_documents_per_run: 1 + max_comments_per_run: 5 + max_comments_per_day: 30 + poll_interval_seconds: 60 +mentions: + trigger: "@knowledge-manager" + invoker_source: "writer-capabilities" +moderation: + blocked_authors: [] +draft_only: false +language: en +\`\`\` +` + +describe('extractYamlBlock', () => { + it('prefers fenced yaml block over frontmatter', () => { + const yaml = extractYamlBlock(RULES_DOC) + expect(yaml).toContain('allow_write_paths') + expect(yaml).not.toContain('type: agent-rules') + }) + + it('falls back to frontmatter when no fenced block exists', () => { + const yaml = extractYamlBlock('---\nfoo: bar\n---\n# Title\n') + expect(yaml).toBe('foo: bar') + }) +}) + +describe('parseRulesBody', () => { + it('accepts the canonical template shape', () => { + const rules = parseRulesBody(RULES_DOC) + expect(rules).not.toBeNull() + expect(rules?.allowWritePaths).toEqual(['/']) + expect(rules?.denyWritePaths).toContain('/agents/knowledge-manager/charter') + expect(rules?.draftOnly).toBe(false) + expect(rules?.language).toBe('en') + expect(rules?.caps.maxDocumentsPerRun).toBe(1) + expect(rules?.caps.maxCommentsPerDay).toBe(30) + expect(rules?.mentions.invokerSource).toBe('writer-capabilities') + }) + + it('flips draft_only true', () => { + const doc = RULES_DOC.replace('draft_only: false', 'draft_only: true') + const rules = parseRulesBody(doc) + expect(rules?.draftOnly).toBe(true) + }) + + it('returns null on garbage', () => { + expect(parseRulesBody('no yaml here')).toBeNull() + }) +}) + +describe('parseAllowlistBody', () => { + const ALLOWLIST_DOC = `# Allowlist +\`\`\`yaml +invokers: + - z6Mkfoo + - z6Mkbar +\`\`\` +` + + it('parses invoker list', () => { + const a = parseAllowlistBody(ALLOWLIST_DOC) + expect(a?.invokers).toEqual(['z6Mkfoo', 'z6Mkbar']) + }) + + it('treats missing list as empty', () => { + const a = parseAllowlistBody('```yaml\ninvokers: []\n```') + expect(a?.invokers).toEqual([]) + }) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.ts new file mode 100644 index 000000000..71847219e --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/governance.ts @@ -0,0 +1,174 @@ +/** + * Governance rules loader. Reads the four governance Seed documents + * (`charter`, `rules`, `runbook`, `allowlist`) under + * `${governanceBasePath}` of `${SEED_SITE}`, parses the machine-readable + * YAML block out of `rules` and `allowlist`, and caches the result for + * `${rulesTtlMs}` so policy changes propagate within ≤ TTL. + * + * The agent's MCP tools call `getRules()` at the start of every tool + * invocation. Cache is process-local; restarting the gateway clears it. + */ + +import {parse as parseYaml} from 'yaml' +import type {AgentConfig} from './config.js' +import type {SeedCli} from './seedcli.js' +import type {Rules} from './limits.js' + +export type Allowlist = { + invokers: string[] +} + +export type Governance = { + rules: Rules + allowlist: Allowlist + charter: string + runbook: string + fetchedAt: number +} + +const DEFAULT_RULES: Rules = { + schemaVersion: 1, + allowWritePaths: ['/'], + denyWritePaths: [ + '/agents/knowledge-manager/charter', + '/agents/knowledge-manager/rules', + '/agents/knowledge-manager/runbook', + '/agents/knowledge-manager/allowlist', + ], + caps: { + maxDocumentsPerRun: 1, + maxCommentsPerRun: 5, + maxCommentsPerDay: 30, + pollIntervalSeconds: 60, + }, + mentions: { + trigger: '@knowledge-manager', + invokerSource: 'writer-capabilities', + }, + moderation: {blockedAuthors: []}, + draftOnly: false, + language: 'en', +} + +export class GovernanceCache { + private cached?: Governance + + constructor( + private readonly config: AgentConfig, + private readonly cli: SeedCli, + ) {} + + async getGovernance(force = false): Promise { + const now = Date.now() + if (!force && this.cached && now - this.cached.fetchedAt < this.config.rulesTtlMs) { + return this.cached + } + const [charter, rules, runbook, allowlist] = await Promise.all([ + this.fetchDocBody('charter'), + this.fetchDocBody('rules'), + this.fetchDocBody('runbook'), + this.fetchDocBody('allowlist'), + ]) + const parsedRules = parseRulesBody(rules) ?? DEFAULT_RULES + const parsedAllowlist = parseAllowlistBody(allowlist) ?? {invokers: []} + this.cached = { + rules: parsedRules, + allowlist: parsedAllowlist, + charter, + runbook, + fetchedAt: now, + } + return this.cached + } + + /** Returns true if the doc was missing (404-ish) so callers can bootstrap. */ + async checkBootstrapNeeded(): Promise { + const rules = await this.fetchDocBody('rules').catch(() => '') + return rules.length === 0 + } + + private async fetchDocBody(slug: string): Promise { + const docId = `${this.config.seedSite}${this.config.governanceBasePath}/${slug}` + // seed-cli's default `document get` already emits markdown with YAML + // frontmatter, which is exactly what the wrapper's parser expects. + // Older builds lack the explicit `--md/--frontmatter` flags. + const result = await this.cli + .runRead(['document', 'get', docId]) + .catch((err) => ({exitCode: -1, stdout: '', stderr: String(err)})) + if (result.exitCode !== 0) return '' + return result.stdout + } +} + +export function parseRulesBody(body: string): Rules | null { + const yaml = extractYamlBlock(body) + if (!yaml) return null + try { + const parsed = parseYaml(yaml) as Partial & Record + return mergeRules(parsed) + } catch { + return null + } +} + +export function parseAllowlistBody(body: string): Allowlist | null { + const yaml = extractYamlBlock(body) + if (!yaml) return null + try { + const parsed = parseYaml(yaml) as {invokers?: unknown} + const invokers = Array.isArray(parsed.invokers) ? parsed.invokers.filter((x): x is string => typeof x === 'string') : [] + return {invokers} + } catch { + return null + } +} + +export function extractYamlBlock(body: string): string | null { + // Look for first fenced ```yaml block (the convention used by the + // `agent-rules.md` template). Fall back to leading frontmatter. + const fenced = body.match(/```ya?ml\s*\n([\s\S]*?)```/) + if (fenced) return fenced[1]! + const frontmatter = body.match(/^---\s*\n([\s\S]*?)\n---/) + if (frontmatter) return frontmatter[1]! + return null +} + +function mergeRules(input: Partial & Record): Rules { + const rules: Rules = { + ...DEFAULT_RULES, + ...input, + caps: {...DEFAULT_RULES.caps, ...(input.caps as Rules['caps'] | undefined)}, + mentions: {...DEFAULT_RULES.mentions, ...(input.mentions as Rules['mentions'] | undefined)}, + moderation: {...DEFAULT_RULES.moderation, ...(input.moderation as Rules['moderation'] | undefined)}, + } + // Snake-case → camelCase tolerance for fields we expect humans to edit. + const camel = (input as Record) as { + allow_write_paths?: string[] + deny_write_paths?: string[] + schema_version?: number + draft_only?: boolean + } + if (Array.isArray(camel.allow_write_paths)) rules.allowWritePaths = camel.allow_write_paths + if (Array.isArray(camel.deny_write_paths)) rules.denyWritePaths = camel.deny_write_paths + if (typeof camel.schema_version === 'number') rules.schemaVersion = camel.schema_version + if (typeof camel.draft_only === 'boolean') rules.draftOnly = camel.draft_only + // Caps snake-case + const caps = (input.caps as Record) ?? {} + if (typeof caps.max_documents_per_run === 'number') rules.caps.maxDocumentsPerRun = caps.max_documents_per_run + if (typeof caps.max_comments_per_run === 'number') rules.caps.maxCommentsPerRun = caps.max_comments_per_run + if (typeof caps.max_comments_per_day === 'number') rules.caps.maxCommentsPerDay = caps.max_comments_per_day + if (typeof caps.poll_interval_seconds === 'number') rules.caps.pollIntervalSeconds = caps.poll_interval_seconds + // Mentions invoker_source + const m = (input.mentions as Record) ?? {} + if (typeof m.invoker_source === 'string') { + rules.mentions.invokerSource = m.invoker_source as Rules['mentions']['invokerSource'] + } + // Moderation blocked_authors + const mod = (input.moderation as Record) ?? {} + if (Array.isArray(mod.blocked_authors)) { + rules.moderation.blockedAuthors = mod.blocked_authors.filter((x): x is string => typeof x === 'string') + } + return rules +} + +export {DEFAULT_RULES} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/index.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/index.ts new file mode 100644 index 000000000..780dc1e59 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/index.ts @@ -0,0 +1,90 @@ +/** + * stdio MCP server entry point. Boots the wrapper, registers all tools, + * and connects to nanobot's stdio transport. + * + * Lifecycle: + * 1. Parse env (SEED_SERVER, SEED_SITE, KM_KEY_NAME, KM_STATE_DIR, KM_LOGS_DIR). + * 2. Build a Redactor from secret env vars. + * 3. Start an AuditRun for this process invocation. + * 4. Resolve the agent's accountId by calling `seed-cli key list`. + * 5. Register MCP tools. + * 6. Listen on stdio until parent exits, then close the audit run. + */ + +import {Server} from '@modelcontextprotocol/sdk/server/index.js' +import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js' +import {AuditRun} from './audit.js' +import {loadConfig} from './config.js' +import {GovernanceCache} from './governance.js' +import {buildRedactor} from './redact.js' +import {SeedCli} from './seedcli.js' +import {State} from './state.js' +import {buildTools, registerToolHandlers} from './tools.js' + +async function main(): Promise { + const config = loadConfig() + const redactor = buildRedactor() + const audit = new AuditRun({ + logsDir: config.logsDir, + trigger: process.env.KM_TRIGGER ?? 'mcp-server', + redactor, + seedSite: config.seedSite, + }) + + audit.trace({ + ts: new Date().toISOString(), + level: 'info', + event: 'agent_start', + data: {seedServer: config.seedServer, seedSite: config.seedSite, keyName: config.keyName}, + }) + + const cli = new SeedCli(config, redactor, audit) + const state = new State(config.stateDir) + const governance = new GovernanceCache(config, cli) + const kmAccountId = await resolveAgentAccountId(cli, config.keyName) + audit.meta.kmAccountId = kmAccountId + + const tools = buildTools({config, cli, governance, state, audit, kmAccountId}) + + const server = new Server({name: 'seed-cli-mcp', version: '0.1.0'}, {capabilities: {tools: {}}}) + registerToolHandlers(server, tools) + + const transport = new StdioServerTransport() + await server.connect(transport) + + // Close the audit run when the parent disconnects (stdin EOF). + const close = (status: 'ok' | 'error' = 'ok') => { + audit.trace({ts: new Date().toISOString(), level: 'info', event: 'agent_end', data: {status}}) + audit.close({status, logsDir: config.logsDir}) + } + process.on('SIGINT', () => { + close('ok') + process.exit(0) + }) + process.on('SIGTERM', () => { + close('ok') + process.exit(0) + }) + process.on('exit', () => close('ok')) +} + +async function resolveAgentAccountId(cli: SeedCli, keyName: string): Promise { + const r = await cli.runRead(['key', 'show', keyName]) + if (r.exitCode !== 0) { + throw new Error(`Failed to resolve agent key '${keyName}': ${r.stderr}`) + } + if (r.parsedJson && typeof r.parsedJson === 'object') { + const obj = r.parsedJson as {accountId?: string} + if (obj.accountId) return obj.accountId + } + // Fallback: parse "accountId: z6Mk..." from stdout. + const m = r.stdout.match(/z6Mk[1-9A-HJ-NP-Za-km-z]{40,}/) + if (m) return m[0] + throw new Error(`Could not parse accountId from \`seed-cli key show ${keyName}\` output`) +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('seed-cli-mcp fatal error:', err) + process.exit(1) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.test.ts new file mode 100644 index 000000000..5bc957568 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.test.ts @@ -0,0 +1,93 @@ +import {describe, expect, it} from 'bun:test' +import {bump, checkCap, isWriteAllowed, matchPath, newRateState, normalizePath} from './limits.js' +import {DEFAULT_RULES} from './governance.js' +import type {Rules} from './limits.js' + +describe('matchPath', () => { + it('exact match', () => { + expect(matchPath('/foo/bar', '/foo/bar')).toBe(true) + }) + it('single-star matches one segment', () => { + expect(matchPath('/foo/*', '/foo/bar')).toBe(true) + expect(matchPath('/foo/*', '/foo/bar/baz')).toBe(false) + }) + it('double-star matches any depth', () => { + expect(matchPath('/foo/**', '/foo/bar')).toBe(true) + expect(matchPath('/foo/**', '/foo/bar/baz')).toBe(true) + expect(matchPath('/**', '/anything/here')).toBe(true) + }) + it('normalizes trailing slashes', () => { + expect(normalizePath('/foo/')).toBe('/foo') + expect(normalizePath('foo')).toBe('/foo') + }) + it('sole / matches everything (site-wide allow)', () => { + expect(matchPath('/', '/agents/knowledge-manager/state/boletin/2026-W19')).toBe(true) + expect(matchPath('/', '/digests/foo')).toBe(true) + expect(matchPath('/', '/')).toBe(true) + }) +}) + +describe('isWriteAllowed', () => { + const rules: Rules = { + ...DEFAULT_RULES, + allowWritePaths: ['/'], + denyWritePaths: ['/locked/**'], + } + + it('allows root', () => { + expect(isWriteAllowed('/', rules).allowed).toBe(true) + }) + + it('rejects denylisted', () => { + const r = isWriteAllowed('/locked/safe', rules) + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.reason).toMatch(/rules-deny/) + }) + + it('hardcoded deny beats allow', () => { + const r = isWriteAllowed('/agents/knowledge-manager/rules', {...rules, allowWritePaths: ['/']}) + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.reason).toMatch(/hardcoded-deny/) + }) + + it('rejects when not in allowlist', () => { + const r = isWriteAllowed('/foo', {...rules, allowWritePaths: ['/digests/**']}) + expect(r.allowed).toBe(false) + }) + + it('allows when matched in allowlist with double-star', () => { + const r = isWriteAllowed('/digests/2026-W19', {...rules, allowWritePaths: ['/digests/**']}) + expect(r.allowed).toBe(true) + }) +}) + +describe('rate caps', () => { + it('newRateState empty', () => { + const s = newRateState() + expect(s.perDay).toEqual({}) + expect(s.perRun).toEqual({}) + expect(s.day).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it('checkCap blocks documents over per-run limit', () => { + const rules: Rules = {...DEFAULT_RULES, caps: {...DEFAULT_RULES.caps, maxDocumentsPerRun: 1}} + let state = newRateState() + expect(checkCap(state, 'documents', rules).allowed).toBe(true) + state = bump(state, 'documents') + const r = checkCap(state, 'documents', rules) + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.reason).toMatch(/max_documents_per_run/) + }) + + it('checkCap blocks comments over per-day limit', () => { + const rules: Rules = {...DEFAULT_RULES, caps: {...DEFAULT_RULES.caps, maxCommentsPerDay: 2, maxCommentsPerRun: 100}} + let state = newRateState() + state = bump(state, 'comments') + state = bump(state, 'comments') + // Force per-run counter to be empty to isolate per-day check. + state = {...state, perRun: {}} + const r = checkCap(state, 'comments', rules) + expect(r.allowed).toBe(false) + if (!r.allowed) expect(r.reason).toMatch(/max_comments_per_day/) + }) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.ts new file mode 100644 index 000000000..9ae18bb4d --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/limits.ts @@ -0,0 +1,130 @@ +/** + * Path allow/deny matching and rate caps. The rules object is the + * machine-readable YAML block parsed out of the `agent-rules` Seed + * document; see `governance.ts`. + * + * Globstar semantics: `**` matches any number of path segments, + * `*` matches one segment. + * + * /agents/knowledge-manager/state/** matches /agents/knowledge-manager/state/foo/bar + * /digests/* matches /digests/2026-W19 but not /digests/x/y + * + * Deny always beats allow. The four governance docs are protected by + * a hardcoded denylist regardless of what the rules say. + */ + +export type Rules = { + schemaVersion: number + allowWritePaths: string[] + denyWritePaths: string[] + caps: { + maxDocumentsPerRun: number + maxCommentsPerRun: number + maxCommentsPerDay: number + pollIntervalSeconds: number + } + mentions: { + trigger: string + invokerSource: 'writer-capabilities' | 'allowlist-doc' + } + moderation: { + blockedAuthors: string[] + } + draftOnly: boolean + language: string +} + +const HARDCODED_DENY = [ + '/agents/knowledge-manager/charter', + '/agents/knowledge-manager/rules', + '/agents/knowledge-manager/runbook', + '/agents/knowledge-manager/allowlist', +] + +export function isWriteAllowed(path: string, rules: Rules): {allowed: true} | {allowed: false; reason: string} { + const normalized = normalizePath(path) + for (const pattern of HARDCODED_DENY) { + if (matchPath(pattern, normalized)) { + return {allowed: false, reason: `hardcoded-deny: ${pattern}`} + } + } + for (const pattern of rules.denyWritePaths) { + if (matchPath(pattern, normalized)) { + return {allowed: false, reason: `rules-deny: ${pattern}`} + } + } + for (const pattern of rules.allowWritePaths) { + if (matchPath(pattern, normalized)) { + return {allowed: true} + } + } + return {allowed: false, reason: `not-in-allowlist`} +} + +export function normalizePath(path: string): string { + if (!path) return '/' + if (!path.startsWith('/')) path = '/' + path + return path.replace(/\/+$/, '') || '/' +} + +export function matchPath(pattern: string, path: string): boolean { + const p = normalizePath(pattern) + const t = normalizePath(path) + if (p === t) return true + // Sole `/` means "root and everything below it" — i.e. site-wide allow. + if (p === '/') return true + // Convert glob to regex. + const regex = '^' + p + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + // double-star: any number of segments + .replace(/\*\*/g, '«DOUBLESTAR»') + // single star: one segment + .replace(/\*/g, '[^/]+') + .replace(/«DOUBLESTAR»/g, '.*') + '$' + return new RegExp(regex).test(t) +} + +// ─── Rate caps ────────────────────────────────────────────────────────────── + +export type RateState = { + /** Calendar day (UTC) the per-day counters belong to. */ + day: string + perDay: Record + perRun: Record +} + +export function newRateState(): RateState { + return {day: utcDay(new Date()), perDay: {}, perRun: {}} +} + +export function bump(state: RateState, key: string): RateState { + const today = utcDay(new Date()) + const next = state.day === today ? state : {...state, day: today, perDay: {}} + next.perDay = {...next.perDay, [key]: (next.perDay[key] ?? 0) + 1} + next.perRun = {...next.perRun, [key]: (next.perRun[key] ?? 0) + 1} + return next +} + +export function checkCap( + state: RateState, + key: 'documents' | 'comments', + rules: Rules, +): {allowed: true} | {allowed: false; reason: string} { + const today = utcDay(new Date()) + const dayCount = state.day === today ? state.perDay[key] ?? 0 : 0 + const runCount = state.perRun[key] ?? 0 + if (key === 'documents' && runCount >= rules.caps.maxDocumentsPerRun) { + return {allowed: false, reason: `cap: max_documents_per_run (${rules.caps.maxDocumentsPerRun}) reached`} + } + if (key === 'comments' && runCount >= rules.caps.maxCommentsPerRun) { + return {allowed: false, reason: `cap: max_comments_per_run (${rules.caps.maxCommentsPerRun}) reached`} + } + if (key === 'comments' && dayCount >= rules.caps.maxCommentsPerDay) { + return {allowed: false, reason: `cap: max_comments_per_day (${rules.caps.maxCommentsPerDay}) reached`} + } + return {allowed: true} +} + +export function utcDay(date: Date): string { + return date.toISOString().slice(0, 10) +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/mention-machine.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/mention-machine.ts new file mode 100644 index 000000000..8e41424ea --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/mention-machine.ts @@ -0,0 +1,222 @@ +/** + * Per-mention XState v5 actor machine. + * + * Replaces the implicit two-pass placeholder/finalise loop in `poll-cli.ts` + * with an explicit lifecycle that supports retry-with-backoff, snapshot/replay + * on crash, and inspectable state in audit logs. + * + * States: + * detected → enqueued → placeholder_pending → placeholder_posted → + * agent_running → draft_ready → finalising → done + * + * Terminal failure states: `failed_terminal`, `skipped_not_allowed`, `cap_exceeded`. + * + * Snapshot: each transition appends to `${stateDir}/machines/.jsonl` + * (event log). On startup the supervisor (`./supervisor.ts`) replays each + * file's events to rehydrate the machine. The machine itself does not write + * to disk — the supervisor wires `subscribe()` to a JSONL writer. + */ + +import {assign, fromPromise, setup} from 'xstate' +import type {Mention} from '../mentions.js' + +const MAX_DRAFT_RETRIES = 3 +const MAX_FINALISE_RETRIES = 3 +const BASE_BACKOFF_MS = 2_000 + +export type MentionContext = { + mention: Mention + /** Comment id of the placeholder posted in `placeholder_posted`. */ + placeholderId: string | null + /** Final reply body (DeepSeek output or fallback). */ + replyBody: string | null + /** Reason for terminal failure. */ + failureReason: string | null + /** Per-state retry counters. */ + draftRetries: number + finaliseRetries: number + /** Last error seen on a transient transition. */ + lastError: string | null +} + +export type MentionEvent = + | {type: 'ENQUEUE'} + | {type: 'CAP_DENIED'; reason: string} + | {type: 'NOT_ALLOWED'; reason: string} + | {type: 'POST_PLACEHOLDER'} + | {type: 'PLACEHOLDER_POSTED'; placeholderId: string} + | {type: 'PLACEHOLDER_FAILED'; reason: string} + | {type: 'RUN_AGENT'} + | {type: 'AGENT_DONE'; replyBody: string} + | {type: 'AGENT_ERROR'; reason: string} + | {type: 'FINALISE'} + | {type: 'FINALISED'} + | {type: 'FINALISE_ERROR'; reason: string} + +export type MentionInput = {mention: Mention} + +/** Caller-provided side effects. The machine has no I/O of its own — the + * supervisor injects callbacks that touch the network or seed-cli. */ +export type MentionCallbacks = { + postPlaceholder: (mention: Mention) => Promise<{placeholderId: string}> + runAgent: (mention: Mention) => Promise<{replyBody: string}> + finaliseComment: (placeholderId: string, replyBody: string) => Promise + /** Optional pre-checks. Throw to short-circuit into a terminal state. */ + checkAllowed?: (mention: Mention) => 'allowed' | {reason: string} + checkCap?: (mention: Mention) => 'allowed' | {reason: string} +} + +export const mentionMachine = setup({ + types: { + context: {} as MentionContext, + events: {} as MentionEvent, + input: {} as MentionInput, + }, + actors: { + postPlaceholderActor: fromPromise<{placeholderId: string}, {mention: Mention; cb: MentionCallbacks}>( + async ({input}) => input.cb.postPlaceholder(input.mention), + ), + runAgentActor: fromPromise<{replyBody: string}, {mention: Mention; cb: MentionCallbacks}>( + async ({input}) => input.cb.runAgent(input.mention), + ), + finaliseActor: fromPromise( + async ({input}) => input.cb.finaliseComment(input.placeholderId, input.replyBody), + ), + }, + guards: { + canRetryDraft: ({context}) => context.draftRetries < MAX_DRAFT_RETRIES, + canRetryFinalise: ({context}) => context.finaliseRetries < MAX_FINALISE_RETRIES, + }, + delays: { + draftBackoff: ({context}) => BASE_BACKOFF_MS * 2 ** context.draftRetries, + finaliseBackoff: ({context}) => BASE_BACKOFF_MS * 2 ** context.finaliseRetries, + }, +}).createMachine({ + id: 'mention', + initial: 'detected', + context: ({input}) => ({ + mention: input.mention, + placeholderId: null, + replyBody: null, + failureReason: null, + draftRetries: 0, + finaliseRetries: 0, + lastError: null, + }), + states: { + detected: { + on: { + ENQUEUE: 'enqueued', + NOT_ALLOWED: { + target: 'skipped_not_allowed', + actions: assign({failureReason: ({event}) => event.reason}), + }, + CAP_DENIED: { + target: 'cap_exceeded', + actions: assign({failureReason: ({event}) => event.reason}), + }, + }, + }, + enqueued: { + on: { + POST_PLACEHOLDER: 'placeholder_pending', + CAP_DENIED: { + target: 'cap_exceeded', + actions: assign({failureReason: ({event}) => event.reason}), + }, + }, + }, + placeholder_pending: { + on: { + PLACEHOLDER_POSTED: { + target: 'placeholder_posted', + actions: assign({placeholderId: ({event}) => event.placeholderId}), + }, + PLACEHOLDER_FAILED: { + target: 'failed_terminal', + actions: assign({failureReason: ({event}) => event.reason}), + }, + }, + }, + placeholder_posted: { + on: { + RUN_AGENT: 'agent_running', + }, + }, + agent_running: { + on: { + AGENT_DONE: { + target: 'draft_ready', + actions: assign({replyBody: ({event}) => event.replyBody}), + }, + AGENT_ERROR: [ + { + guard: 'canRetryDraft', + target: 'agent_backoff', + actions: assign({ + draftRetries: ({context}) => context.draftRetries + 1, + lastError: ({event}) => event.reason, + }), + }, + { + target: 'failed_terminal', + actions: assign({failureReason: ({event}) => event.reason}), + }, + ], + }, + }, + agent_backoff: { + after: { + draftBackoff: 'agent_running', + }, + }, + draft_ready: { + on: { + FINALISE: 'finalising', + }, + }, + finalising: { + on: { + FINALISED: 'done', + FINALISE_ERROR: [ + { + guard: 'canRetryFinalise', + target: 'finalise_backoff', + actions: assign({ + finaliseRetries: ({context}) => context.finaliseRetries + 1, + lastError: ({event}) => event.reason, + }), + }, + { + target: 'failed_terminal', + actions: assign({failureReason: ({event}) => event.reason}), + }, + ], + }, + }, + finalise_backoff: { + after: { + finaliseBackoff: 'finalising', + }, + }, + done: {type: 'final'}, + skipped_not_allowed: {type: 'final'}, + cap_exceeded: {type: 'final'}, + failed_terminal: {type: 'final'}, + }, +}) + +export type MentionState = + | 'detected' + | 'enqueued' + | 'placeholder_pending' + | 'placeholder_posted' + | 'agent_running' + | 'agent_backoff' + | 'draft_ready' + | 'finalising' + | 'finalise_backoff' + | 'done' + | 'skipped_not_allowed' + | 'cap_exceeded' + | 'failed_terminal' diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/poll-driver.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/poll-driver.ts new file mode 100644 index 000000000..198f9cac0 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/poll-driver.ts @@ -0,0 +1,153 @@ +/** + * Glue between poll-cli's PASS B and the per-mention XState supervisor. + * + * For each pending placeholder, spawn a machine, supply callbacks that run + * the existing reply-engine.draftReply (or Mastra agent when configured), + * and wait for the machine to reach a terminal state. + */ + +import type {AuditRun} from '../audit.js' +import type {AgentConfig} from '../config.js' +import type {SeedCli} from '../seedcli.js' +import type {State, PlaceholderRecord} from '../state.js' +import {draftReply, gatherCommentReplyContext} from '../reply-engine.js' +import {MentionSupervisor} from './supervisor.js' + +export type RunMachinePassBOptions = { + config: AgentConfig + cli: SeedCli + state: State + audit: AuditRun + pending: PlaceholderRecord[] + siteAccount: string + fallbackBody: string +} + +export async function runMachinePassB(opts: RunMachinePassBOptions): Promise<{finalised: number; errored: number}> { + const {config, cli, state, audit, pending, siteAccount, fallbackBody} = opts + let finalised = 0 + let errored = 0 + + const supervisor = new MentionSupervisor( + config.stateDir, + { + // Placeholder is already posted in Pass A; the machine starts straight at + // `placeholder_posted` by sending POST_PLACEHOLDER + PLACEHOLDER_POSTED in + // sequence below. + postPlaceholder: async () => ({placeholderId: ''}), + runAgent: async (mention) => { + const question = mention.text.replace(//g, ' ').trim() + const context = await gatherCommentReplyContext({cli, mention, siteAccount, audit}) + if (config.useMastraAgent) { + const {runMastraReply} = await import('../agent/mastra-agent.js') + const reply = await runMastraReply({question, context, mention, audit, cli}) + return {replyBody: reply ?? fallbackBody} + } + const reply = await draftReply(question, context, audit) + return {replyBody: reply ?? fallbackBody} + }, + finaliseComment: async (placeholderId, replyBody) => { + const r = await cli.runWrite(['comment', 'edit', placeholderId, '--body', replyBody]) + if (r.exitCode !== 0) { + throw new Error(`comment edit failed: exit=${r.exitCode} stderr=${r.stderr.slice(0, 200)}`) + } + }, + }, + { + runId: audit.meta.runId, + telemetry: (kind, data) => audit.telemetry(kind, data), + }, + ) + + // Replay any actors persisted from prior runs so we resume mid-flight. + const replay = supervisor.rehydrate() + if (replay.restored > 0) { + audit.trace({ + ts: new Date().toISOString(), + level: 'info', + event: 'state_machine_rehydrated', + data: replay, + }) + } + + for (const rec of pending) { + const actor = supervisor.spawn(rec.mention) + // The mention was already moved through detection and placeholder-posting + // by Pass A. Feed those events to the machine so it lands in `placeholder_posted` + // and the agent stage runs from there. + supervisor.send(rec.mention, {type: 'POST_PLACEHOLDER'}) + supervisor.send(rec.mention, {type: 'PLACEHOLDER_POSTED', placeholderId: rec.placeholderId}) + supervisor.send(rec.mention, {type: 'RUN_AGENT'}) + + let replyBody: string | null = null + try { + const cb = (actor as any).logic.config.actors as never + void cb // satisfy noUnusedLocals while keeping types intact + const ran = await runAgentForActor(rec, opts) + replyBody = ran.replyBody + supervisor.send(rec.mention, {type: 'AGENT_DONE', replyBody}) + } catch (err) { + const reason = err instanceof Error ? err.message : String(err) + supervisor.send(rec.mention, {type: 'AGENT_ERROR', reason}) + audit.trace({ + ts: new Date().toISOString(), + level: 'error', + event: 'agent_error', + data: {commentId: rec.mention.commentId, reason}, + }) + errored++ + continue + } + + supervisor.send(rec.mention, {type: 'FINALISE'}) + try { + const r = await cli.runWrite(['comment', 'edit', rec.placeholderId, '--body', replyBody!]) + if (r.exitCode !== 0) { + throw new Error(`comment edit failed: exit=${r.exitCode} stderr=${r.stderr.slice(0, 200)}`) + } + supervisor.send(rec.mention, {type: 'FINALISED'}) + state.finalisePlaceholder(rec.mentionId, rec.placeholderId) + state.markProcessed(rec.mention, audit.meta.runId, replyBody ? 'replied' : 'error') + audit.trace({ + ts: new Date().toISOString(), + level: 'info', + event: 'reply_finalised', + data: { + commentId: rec.mention.commentId, + placeholderId: rec.placeholderId, + replyPreview: replyBody!.slice(0, 200), + }, + }) + finalised++ + } catch (err) { + const reason = err instanceof Error ? err.message : String(err) + supervisor.send(rec.mention, {type: 'FINALISE_ERROR', reason}) + audit.trace({ + ts: new Date().toISOString(), + level: 'error', + event: 'reply_edit_failed', + data: {commentId: rec.mention.commentId, placeholderId: rec.placeholderId, reason}, + }) + errored++ + } + } + + supervisor.stopAll() + return {finalised, errored} +} + +async function runAgentForActor( + rec: PlaceholderRecord, + opts: RunMachinePassBOptions, +): Promise<{replyBody: string}> { + const {config, cli, audit, siteAccount, fallbackBody} = opts + const question = rec.mention.text.replace(//g, ' ').trim() + const context = await gatherCommentReplyContext({cli, mention: rec.mention, siteAccount, audit}) + if (config.useMastraAgent) { + const {runMastraReply} = await import('../agent/mastra-agent.js') + const reply = await runMastraReply({question, context, mention: rec.mention, audit, cli}) + return {replyBody: reply ?? fallbackBody} + } + const reply = await draftReply(question, context, audit) + return {replyBody: reply ?? fallbackBody} +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts new file mode 100644 index 000000000..b4df7040c --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/machines/supervisor.ts @@ -0,0 +1,239 @@ +/** + * Supervisor for per-mention machines. Loads pending state from disk on + * startup, spawns one machine per mention, persists every transition to a + * JSONL event log, and rehydrates machines after a crash. + * + * The supervisor is the only component that touches `${stateDir}/machines/`. + * Each mention has a dedicated event log: + * + * ${stateDir}/machines/.jsonl + * + * Each line is `{ts, type, payload}`. Replay = create a fresh machine with + * the original input, then `actor.send(event)` for each persisted event. + */ + +import {appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync} from 'node:fs' +import {join} from 'node:path' +import {createActor, type Actor} from 'xstate' +import {mentionMachine, type MentionCallbacks, type MentionEvent} from './mention-machine.js' +import type {Mention} from '../mentions.js' +import {mentionKey} from '../state.js' +import type {TelemetryKind} from '../observability.js' + +const MACHINES_SUBDIR = 'machines' + +type PersistedEvent = { + ts: string + type: MentionEvent['type'] + payload?: Record + /** Captured on the very first line so replay can reconstruct context. */ + initialMention?: Mention +} + +export class MentionSupervisor { + private readonly machinesDir: string + private readonly actors = new Map>() + private readonly callbacks: MentionCallbacks + private readonly telemetry?: (kind: Extract, data: unknown) => void + private readonly runId?: string + + constructor( + stateDir: string, + callbacks: MentionCallbacks, + opts: { + telemetry?: (kind: Extract, data: unknown) => void + runId?: string + } = {}, + ) { + this.callbacks = callbacks + void this.callbacks + this.telemetry = opts.telemetry + this.runId = opts.runId + this.machinesDir = join(stateDir, MACHINES_SUBDIR) + if (!existsSync(this.machinesDir)) { + mkdirSync(this.machinesDir, {recursive: true, mode: 0o700}) + } + } + + /** Returns true when this mention already has a non-terminal actor. */ + has(mention: Mention): boolean { + const id = mentionKey(mention) + return this.actors.has(id) + } + + /** Spawn a fresh machine for a newly-detected mention. */ + spawn(mention: Mention): Actor { + const id = mentionKey(mention) + if (this.actors.has(id)) return this.actors.get(id)! + + // First line of the log captures the input mention so replay can + // reconstruct identical machine context. + this.persist(id, {ts: new Date().toISOString(), type: 'ENQUEUE', initialMention: mention}) + + const actor = this.createActor(mention, id) + actor.start() + this.emitMachineEvent('actor_spawned', mention, id, {trigger: 'fresh'}) + return actor + } + + /** Send an event to a mention's actor. Auto-persists, then forwards. */ + send(mention: Mention, event: MentionEvent): void { + const id = mentionKey(mention) + const actor = this.actors.get(id) + if (!actor) return + this.persist(id, {ts: new Date().toISOString(), type: event.type, payload: extractPayload(event)}) + this.emitMachineEvent('actor_event', mention, id, {type: event.type, payload: extractPayload(event)}) + actor.send(event) + } + + /** Read all *.jsonl files and replay them into fresh actors. Drops actors + * whose final event is a terminal state (they are already done). */ + rehydrate(): {restored: number; skipped: number} { + if (!existsSync(this.machinesDir)) return {restored: 0, skipped: 0} + let restored = 0 + let skipped = 0 + for (const file of readdirSync(this.machinesDir)) { + if (!file.endsWith('.jsonl')) continue + const lines = readFileSync(join(this.machinesDir, file), 'utf-8') + .split('\n') + .filter(Boolean) + if (lines.length === 0) continue + let initialMention: Mention | null = null + const events: MentionEvent[] = [] + for (const line of lines) { + try { + const parsed = JSON.parse(line) as PersistedEvent + if (parsed.initialMention && !initialMention) { + initialMention = parsed.initialMention + } + // Skip the bootstrap ENQUEUE line — it's a marker, not a real event; + // creating the actor naturally starts in the `detected` state and + // an ENQUEUE event from the next entry takes it to `enqueued`. + if (parsed.initialMention) continue + events.push(reconstructEvent(parsed)) + } catch { + // Corrupt line — best-effort skip. + } + } + if (!initialMention) { + skipped++ + continue + } + // Derive the original (unsanitized) mention id from the persisted + // initialMention so the in-memory actor map keys match what later + // spawn()/send() calls compute via mentionKey(). + const id = mentionKey(initialMention) + const actor = this.createActor(initialMention, id, {silent: true}) + actor.start() + for (const e of events) { + actor.send(e) + } + const snapshot = actor.getSnapshot() + if (snapshot.status === 'done') { + actor.stop() + this.actors.delete(id) + this.emitMachineEvent('actor_rehydrated_terminal', initialMention, id, {status: snapshot.status}) + skipped++ + } else { + this.emitMachineEvent('actor_rehydrated', initialMention, id, {status: snapshot.status}) + restored++ + } + } + return {restored, skipped} + } + + /** Stop all actors. Called on graceful shutdown. */ + stopAll(): void { + for (const [id, actor] of this.actors.entries()) { + actor.stop() + this.telemetry?.('machine_event', { + event: 'actor_stopped', + ts: new Date().toISOString(), + runId: this.runId, + actorId: id, + mentionId: id, + }) + } + this.actors.clear() + } + + private createActor(mention: Mention, id: string, opts: {silent?: boolean} = {}): Actor { + const actor = createActor(mentionMachine, {input: {mention}}) + this.actors.set(id, actor) + actor.subscribe((snapshot) => { + if (!opts.silent) this.emitSnapshot(mention, id, snapshot) + if (snapshot.status === 'done') { + // Terminal — drop the actor. The JSONL log stays as audit trail. + setImmediate(() => { + actor.stop() + this.actors.delete(id) + this.emitMachineEvent('actor_stopped', mention, id, {status: snapshot.status}) + }) + } + }) + return actor + } + + private emitMachineEvent(event: string, mention: Mention, id: string, data: Record = {}): void { + this.telemetry?.('machine_event', { + event, + ts: new Date().toISOString(), + runId: this.runId, + actorId: id, + mentionId: id, + commentId: mention.commentId, + docId: mention.docId, + kind: mention.kind, + ...data, + }) + } + + private emitSnapshot(mention: Mention, id: string, snapshot: unknown): void { + const snap = isRecord(snapshot) ? snapshot : {} + const context = isRecord(snap.context) ? snap.context : {} + this.telemetry?.('machine_snapshot', { + event: 'actor_snapshot', + ts: new Date().toISOString(), + runId: this.runId, + actorId: id, + mentionId: id, + commentId: mention.commentId, + docId: mention.docId, + state: snap.value, + status: snap.status, + context: { + placeholderId: context.placeholderId, + draftRetries: context.draftRetries, + finaliseRetries: context.finaliseRetries, + failureReason: context.failureReason, + lastError: context.lastError, + }, + }) + } + + private persist(id: string, event: PersistedEvent): void { + const path = join(this.machinesDir, `${sanitizeForFs(id)}.jsonl`) + appendFileSync(path, JSON.stringify(event) + '\n', {mode: 0o600}) + } +} + +/** Mention ids include "/" (commentId is "/"). Replace any + * filesystem-unfriendly chars so the per-mention JSONL stays a single file + * under the machines/ directory. */ +function sanitizeForFs(id: string): string { + return id.replace(/[/\\:]/g, '_') +} + +function extractPayload(event: MentionEvent): Record | undefined { + const {type: _type, ...rest} = event as Record & {type: string} + return Object.keys(rest).length ? rest : undefined +} + +function reconstructEvent(persisted: PersistedEvent): MentionEvent { + const base = {type: persisted.type as MentionEvent['type']} + return {...base, ...(persisted.payload ?? {})} as MentionEvent +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.test.ts new file mode 100644 index 000000000..5152d843d --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.test.ts @@ -0,0 +1,141 @@ +import {describe, expect, it} from 'bun:test' +import {buildReplyTarget, classifyEvent, extractBlockId, findMentionTargets, mentionsAccount, stripFragment} from './mentions.js' + +const KM = 'z6MkAgentAccount' + +describe('findMentionTargets', () => { + it('extracts hm:// from @[Name](hm://...) syntax', () => { + expect(findMentionTargets('hi @[Bot](hm://z6MkAgentAccount) what is X?')).toEqual(['z6MkAgentAccount']) + }) + + it('handles multiple mentions', () => { + const t = '@[A](hm://z6MkA) and @[B](hm://z6MkB)' + expect(findMentionTargets(t)).toEqual(['z6MkA', 'z6MkB']) + }) + + it('returns empty on plain text', () => { + expect(findMentionTargets('plain text')).toEqual([]) + }) +}) + +describe('classifyEvent — comment mention', () => { + it('matches and returns kind=comment', () => { + const m = classifyEvent( + { + comment: { + id: 'bafyComment1', + target: 'hm://z6MkSite/some/doc', + body: '@[KM](hm://z6MkAgentAccount) hi', + author: 'z6MkAuthor', + time: '2026-05-05T00:00:00Z', + }, + }, + KM, + ) + expect(m).not.toBeNull() + expect(m?.kind).toBe('comment') + expect(m?.docId).toBe('hm://z6MkSite/some/doc') + expect(m?.commentId).toBe('bafyComment1') + expect(m?.author).toBe('z6MkAuthor') + }) + + it('extracts blockId from target fragment', () => { + const m = classifyEvent( + { + comment: { + id: 'bafyComment2', + target: 'hm://z6MkSite/some/doc#blk-abc', + body: '@[KM](hm://z6MkAgentAccount) ?', + author: 'z6MkAuthor', + }, + }, + KM, + ) + expect(m?.blockId).toBe('blk-abc') + }) + + it('returns null when mention is for a different account', () => { + const m = classifyEvent( + { + comment: { + id: 'bafy3', + target: 'hm://z6MkSite/some/doc', + body: '@[Other](hm://z6MkOther) hi', + author: 'z6MkAuthor', + }, + }, + KM, + ) + expect(m).toBeNull() + }) +}) + +describe('classifyEvent — document mention', () => { + it('finds mention inside any block', () => { + const m = classifyEvent( + { + document: { + id: 'hm://z6MkSite/page', + author: 'z6MkAuthor', + blocks: [ + {id: 'blk1', text: 'intro'}, + {id: 'blk2', text: 'see also @[KM](hm://z6MkAgentAccount)'}, + ], + }, + }, + KM, + ) + expect(m).not.toBeNull() + expect(m?.kind).toBe('doc-block') + expect(m?.blockId).toBe('blk2') + expect(m?.docId).toBe('hm://z6MkSite/page') + }) + + it('returns null without any mention block', () => { + const m = classifyEvent({document: {id: 'hm://x', author: 'z6Mky', blocks: [{id: '1', text: 'plain'}]}}, KM) + expect(m).toBeNull() + }) +}) + +describe('mentionsAccount / fragment helpers', () => { + it('mentionsAccount', () => { + expect(mentionsAccount('hi @[K](hm://z6Mkx)', 'z6Mkx')).toBe(true) + expect(mentionsAccount('hi @[K](hm://z6Mkx)', 'z6Mky')).toBe(false) + }) + it('extractBlockId', () => { + expect(extractBlockId('hm://x/y#blk-1')).toBe('blk-1') + expect(extractBlockId('hm://x/y')).toBeUndefined() + }) + it('stripFragment', () => { + expect(stripFragment('hm://x#blk')).toBe('hm://x') + }) +}) + +describe('buildReplyTarget', () => { + it('threaded reply for comment mentions does NOT append the comment-internal blockId to the doc URL', () => { + const r = buildReplyTarget({ + kind: 'comment', + docId: 'hm://z6Mksite/doc', + blockId: 'blk1', // belongs to the COMMENT, not the doc + commentId: 'bafyc1', + author: 'z6Mka', + text: '...', + ts: '', + }) + expect(r.targetId).toBe('hm://z6Mksite/doc') + expect(r.replyTo).toBe('bafyc1') + }) + + it('block-anchored top-level for doc mentions', () => { + const r = buildReplyTarget({ + kind: 'doc-block', + docId: 'hm://z6Mksite/doc', + blockId: 'blk2', + author: 'z6Mka', + text: '...', + ts: '', + }) + expect(r.targetId).toBe('hm://z6Mksite/doc#blk2') + expect(r.replyTo).toBeUndefined() + }) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts new file mode 100644 index 000000000..f770ae9ce --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/mentions.ts @@ -0,0 +1,316 @@ +/** + * Mention parsing and classification. + * + * Seed represents inline mentions as the literal text `@[Name](hm://accountId)` + * in comment bodies and in document blocks. Block-level comments are + * targeted by appending `#blockId` to the document URL. + * + * `Mention.kind` distinguishes how the agent should respond: + * - `comment` — mention inside a comment body. Reply via threaded + * comment (`comment create --reply `). + * - `doc-block` — mention inside a document block. Reply via a + * block-anchored top-level comment + * (`comment create #`). + */ + +export type MentionKind = 'comment' | 'doc-block' + +/** + * How the mention was discovered. `'mention'` = an explicit `@KM` embed + * or inline `@[…](hm://kmAccountId)` was found in the comment/doc. + * `'thread-reply'` = the comment has no explicit mention but is a reply + * (direct or transitive) inside a thread where KM has already commented, + * so the agent treats it as a continued conversation. Optional in the + * type so legacy `placeholders.jsonl` records (written before this + * field existed) still deserialize cleanly — readers should treat + * `undefined` as `'mention'`. + */ +export type MentionTriggerSource = 'mention' | 'thread-reply' + +export type Mention = { + kind: MentionKind + /** Hypermedia ID of the document the mention lives on. */ + docId: string + /** Block ID where the mention is anchored. May be undefined for comment-body mentions on the doc level. */ + blockId?: string + /** ID of the comment containing the mention (only when kind === 'comment'). */ + commentId?: string + /** AccountId (z6Mk…) of the comment / doc author. */ + author: string + /** Verbatim text containing the mention, used to classify the request. */ + text: string + /** Activity event timestamp (ISO). */ + ts: string + /** Discriminator: explicit mention vs implicit thread-reply trigger. */ + triggerSource?: MentionTriggerSource +} + +const MENTION_RE = /@\[[^\]]*\]\(hm:\/\/([^)#]+)(?:[^)]*)?\)/g + +export function findMentionTargets(text: string): string[] { + const ids: string[] = [] + for (const m of text.matchAll(MENTION_RE)) { + if (m[1]) ids.push(m[1]) + } + return ids +} + +export function mentionsAccount(text: string, accountId: string): boolean { + return findMentionTargets(text).includes(accountId) +} + +/** + * Classifies an activity event into a Mention or null. The shape of + * `event` follows the daemon's `activity` API; we only consume what we + * need so future field additions don't break us. + */ +/** + * Activity-feed event shape (selected fields). Real events include many + * more keys; we read only what we need to identify a candidate comment. + * + * { "id": "bafy...", "type": "comment", "time": "...", + * "author": {"id": {"uid": "z6Mk..."}} } + */ +export type ActivityEvent = { + id?: string + type?: string + time?: string + author?: string | {id?: {uid?: string}} +} + +export function unwrapAuthor(a: ActivityEvent['author']): string | undefined { + if (typeof a === 'string') return a + if (a && typeof a === 'object') return a.id?.uid + return undefined +} + +/** + * Returns the comment record id to fetch if this event is a comment with + * an author. Activity events store the comment's record id in `event.id`. + * We don't try to detect mentions inside doc-update events here; that + * requires a separate doc fetch (deferred to v2). + */ +export function commentEventCandidate(event: ActivityEvent): {commentId: string; author: string; ts: string} | null { + if (event.type !== 'comment') return null + if (!event.id) return null + const author = unwrapAuthor(event.author) + if (!author) return null + return {commentId: event.id, author, ts: event.time ?? new Date().toISOString()} +} + +/** + * Shape returned by `seed-cli comment get `. + */ +export type SeedComment = { + id: string + author: string + targetAccount: string + targetPath?: string + replyParent?: string + threadRoot?: string + content?: Array<{ + block?: { + id?: string + text?: string + annotations?: Array<{type?: string; link?: string}> + } + }> +} + +/** + * Detects whether a fetched comment contains a mention of any of the + * given accountIds. Mentions are stored as `Embed` annotations whose + * `link` starts with `hm://`. Seed renders them as a U+FFFC + * object-replacement character with the link held on the annotation. + * + * The agent treats both itself (its own KM_AID) and the site root as + * trigger targets — when a writer mentions the site by name in a + * comment, the agent (which holds a WRITER capability on that site) + * should respond as if mentioned directly. + * + * Returns the {blockId, text} of the first block carrying any matching + * mention, or null otherwise. + */ +export function findKmMentionInComment( + comment: SeedComment, + accountIds: string | readonly string[], +): {blockId?: string; text: string} | null { + const ids = typeof accountIds === 'string' ? [accountIds] : accountIds + const idSet = new Set(ids) + for (const item of comment.content ?? []) { + const block = item.block + if (!block) continue + for (const ann of block.annotations ?? []) { + if (ann.type !== 'Embed') continue + if (typeof ann.link !== 'string') continue + const m = ann.link.match(/^hm:\/\/([^/?#]+)(\/.*)?/) + if (!m) continue + // Document links (hm://account/path) are NOT mentions — skip. + // But /:profile is an account mention, not a document. + if (m[2] && m[2] !== '/:profile' && !m[2].startsWith('/:profile?')) continue + if (idSet.has(m[1]!)) { + return {blockId: block.id, text: block.text ?? ''} + } + } + // Fallback: inline `@[…](hm://aid)` markdown syntax. + if (block.text) { + for (const id of ids) { + if (mentionsAccount(block.text, id)) { + return {blockId: block.id, text: block.text} + } + } + } + } + return null +} + +/** + * Builds a Mention from a fetched comment. Caller has already determined + * the mention-text + blockId via findKmMentionInComment. + */ +export function buildCommentMention( + comment: SeedComment, + evidence: {blockId?: string; text: string}, + ts: string, +): Mention { + const docId = `hm://${comment.targetAccount}${comment.targetPath ?? ''}` + return { + kind: 'comment', + docId, + blockId: evidence.blockId, + commentId: comment.id, + author: comment.author, + text: evidence.text, + ts, + triggerSource: 'mention', + } +} + +/** + * Builds a Mention for a comment that fired the trigger via the + * thread-reply path (no explicit `@KM` embed; an ancestor on the + * replyParent chain was authored by KM). Since there's no embed + * evidence to point at a specific block, we use the comment's full + * body — every block's text joined by `\n`, with U+FFFC + * object-replacement characters replaced by spaces so the LLM sees the + * raw question rather than embed placeholders. `blockId` is omitted — + * the reply is threaded via `--reply commentId`, no doc-block anchor + * needed. + */ +export function buildThreadReplyMention(comment: SeedComment, ts: string): Mention { + const docId = `hm://${comment.targetAccount}${comment.targetPath ?? ''}` + const parts: string[] = [] + for (const item of comment.content ?? []) { + const t = item.block?.text + if (typeof t === 'string') parts.push(t.replace(//g, ' ')) + } + return { + kind: 'comment', + docId, + commentId: comment.id, + author: comment.author, + text: parts.join('\n').trim(), + ts, + triggerSource: 'thread-reply', + } +} + +/** + * Walks the comment's `replyParent` chain looking for an ancestor + * authored by KM. Returns the first KM-authored ancestor's commentId + * (the one closest to the current comment) when found, or null. + * + * `fetchComment` is injected so the detection stays unit-testable + * without shelling out through `seed-cli`. `cache` is shared across + * calls inside a single poll cycle so a deep thread fetched once is + * not refetched when sibling replies trigger lookups. `maxHops` + * defaults to 30 (matches `walkThread` in reply-engine.ts) and a + * `visited` set guards against cycles or self-references in malformed + * chains. + */ +export async function detectThreadReplyToKm(opts: { + comment: SeedComment + kmAccountId: string + fetchComment: (id: string) => Promise + cache: Map + maxHops?: number +}): Promise<{ancestorCommentId: string} | null> { + const {comment, kmAccountId, fetchComment, cache} = opts + const maxHops = opts.maxHops ?? 30 + const visited = new Set([comment.id]) + let parentId = comment.replyParent?.trim() || undefined + for (let hop = 0; hop < maxHops && parentId; hop++) { + if (visited.has(parentId)) return null + visited.add(parentId) + let parent: SeedComment | null | undefined = cache.get(parentId) + if (parent === undefined) { + parent = await fetchComment(parentId) + cache.set(parentId, parent) + } + if (!parent) return null + if (parent.author === kmAccountId) return {ancestorCommentId: parent.id} + parentId = parent.replyParent?.trim() || undefined + } + return null +} + +// Legacy helper kept for tests and the (disabled) inbox_enqueue_from_event tool. +export function classifyEvent(event: ActivityEvent & {comment?: any; document?: any}, kmAccountId: string): Mention | null { + if (event.comment) { + const c = event.comment as {id?: string; target?: string; body?: string; author?: string; time?: string; blockId?: string} + if (!c.body || !c.target || !c.author || !c.id) return null + if (!mentionsAccount(c.body, kmAccountId)) return null + return { + kind: 'comment', + docId: stripFragment(c.target), + blockId: extractBlockId(c.target) ?? c.blockId, + commentId: c.id, + author: c.author, + text: c.body, + ts: c.time ?? new Date().toISOString(), + } + } + if (event.document) { + const d = event.document as {id?: string; blocks?: Array<{id?: string; text?: string}>; author?: string; time?: string} + if (!d.id || !d.author || !Array.isArray(d.blocks)) return null + for (const block of d.blocks) { + if (block.text && mentionsAccount(block.text, kmAccountId)) { + return { + kind: 'doc-block', + docId: d.id, + blockId: block.id, + author: d.author, + text: block.text, + ts: d.time ?? new Date().toISOString(), + } + } + } + } + return null +} + +export function extractBlockId(target: string): string | undefined { + const m = target.match(/#([^?]+)/) + return m?.[1] +} + +export function stripFragment(target: string): string { + return target.split('#')[0]! +} + +export function buildReplyTarget(m: Mention): {targetId: string; replyTo?: string} { + if (m.kind === 'comment') { + // Threaded reply. The blockId on the mention belongs to a block of + // the *comment* (not the parent doc), so we MUST NOT append it to + // the doc URL — that would render as a broken doc-block embed. + // Threading is handled via --reply . + return { + targetId: m.docId, + replyTo: m.commentId, + } + } + // doc-block: anchor a top-level comment to the doc block carrying + // the mention. + if (m.blockId) return {targetId: `${m.docId}#${m.blockId}`} + return {targetId: m.docId} +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.test.ts new file mode 100644 index 000000000..f050058d4 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.test.ts @@ -0,0 +1,33 @@ +import {expect, test} from 'bun:test' +import {sanitizeForObservability} from './observability.js' + +const redactor = (input: string) => input.split('secret-token').join('***REDACTED***') + +test('observability sanitizes LLM prompts by default', () => { + const sanitized = sanitizeForObservability( + 'llm', + { + model: 'deepseek-chat', + prompt_messages: [{role: 'user', content: 'secret-token'}], + completion: 'answer with secret-token', + tool_calls: [{function: {name: 'seed_search', arguments: '{}'}}], + }, + false, + redactor, + ) as Record + + expect(sanitized.prompt_messages).toBeUndefined() + expect(sanitized.completion).toContain('***REDACTED***') + expect(sanitized.tool_calls).toEqual(['seed_search']) +}) + +test('observability can preserve redacted full payload explicitly', () => { + const sanitized = sanitizeForObservability( + 'trace', + {event: 'x', data: {nested: 'secret-token'}}, + true, + redactor, + ) as {data: {nested: string}} + + expect(sanitized.data.nested).toBe('***REDACTED***') +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.ts new file mode 100644 index 000000000..0cd5cbb54 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/observability.ts @@ -0,0 +1,196 @@ +import type {Redactor} from './redact.js' + +export type TelemetryKind = + | 'run_meta' + | 'trace' + | 'llm' + | 'tool' + | 'seed_cli' + | 'machine_event' + | 'machine_snapshot' + +export type ObservabilityClient = { + emit(kind: TelemetryKind, runId: string | null, data: unknown): void + flush(timeoutMs?: number): Promise +} + +type ClientConfig = { + url: string + token?: string + fullPayload: boolean +} + +const STRING_LIMIT = 300 +const LARGE_STRING_LIMIT = 1_200 + +export function createObservabilityClientFromEnv(redactor: Redactor, env: NodeJS.ProcessEnv = process.env): ObservabilityClient | null { + const rawUrl = env.KM_OBS_URL + if (!rawUrl) return null + const url = normalizeIngestUrl(rawUrl) + if (!url) return null + return createObservabilityClient( + { + url, + token: env.KM_OBS_TOKEN, + fullPayload: /^(1|true|yes)$/i.test(env.KM_OBS_FULL_PAYLOAD ?? ''), + }, + redactor, + ) +} + +export function createObservabilityClient(config: ClientConfig, redactor: Redactor): ObservabilityClient { + const pending = new Set>() + const emit = (kind: TelemetryKind, runId: string | null, data: unknown): void => { + const payload = sanitizeForObservability(kind, data, config.fullPayload, redactor) + const envelope = { + kind, + runId, + ts: pickTimestamp(payload), + source: 'km', + data: payload, + } + const task = fetch(config.url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(config.token ? {authorization: `Bearer ${config.token}`} : {}), + }, + body: redactor(JSON.stringify(envelope)), + }) + .then(async (res) => { + if (!res.ok) await res.arrayBuffer().catch(() => undefined) + }) + .catch(() => undefined) + .finally(() => pending.delete(task)) + pending.add(task) + } + return { + emit, + async flush(timeoutMs = 1_500): Promise { + if (pending.size === 0) return + await Promise.race([ + Promise.allSettled(Array.from(pending)).then(() => undefined), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]) + }, + } +} + +export function sanitizeForObservability(kind: TelemetryKind, data: unknown, fullPayload: boolean, redactor: Redactor): unknown { + if (fullPayload) return redactedJsonClone(data, redactor) + const value = redactedJsonClone(data, redactor) + const record = isRecord(value) ? value : {value} + switch (kind) { + case 'llm': + return compactObject({ + ts_start: record.ts_start, + ts_end: record.ts_end, + latency_ms: record.latency_ms, + model: record.model, + completion: truncateString(record.completion, LARGE_STRING_LIMIT), + reasoning: truncateString(record.reasoning, STRING_LIMIT), + usage: limitDeep(record.usage, 2), + tool_call_count: Array.isArray(record.tool_calls) ? record.tool_calls.length : undefined, + tool_calls: Array.isArray(record.tool_calls) + ? record.tool_calls.map((call) => callName(call)).filter((name): name is string => typeof name === 'string') + : undefined, + }) + case 'seed_cli': + return compactObject({ + ts_start: record.ts_start, + ts_end: record.ts_end, + latency_ms: record.latency_ms, + argv: Array.isArray(record.argv) ? record.argv.map((item) => truncateString(item, STRING_LIMIT)) : undefined, + exit_code: record.exit_code, + stdout: truncateString(record.stdout, LARGE_STRING_LIMIT), + stderr: truncateString(record.stderr, LARGE_STRING_LIMIT), + }) + case 'tool': + return compactObject({ + ts_start: record.ts_start, + ts_end: record.ts_end, + latency_ms: record.latency_ms, + tool: record.tool, + args: limitDeep(record.args, 3), + result: limitDeep(record.result, 3), + error: truncateString(record.error, LARGE_STRING_LIMIT), + }) + case 'trace': + return compactObject({ + ts: record.ts, + level: record.level, + event: record.event, + data: limitDeep(record.data, 4), + }) + case 'machine_event': + case 'machine_snapshot': + case 'run_meta': + return limitDeep(record, 5) + } +} + +function normalizeIngestUrl(raw: string): string | null { + try { + const url = new URL(raw) + if (url.pathname === '/' || url.pathname === '') url.pathname = '/api/ingest' + return url.toString() + } catch { + return null + } +} + +function redactedJsonClone(value: unknown, redactor: Redactor): unknown { + try { + return JSON.parse(redactor(JSON.stringify(value))) + } catch { + return redactor(String(value)) + } +} + +function pickTimestamp(value: unknown): string | null { + const record = isRecord(value) ? value : null + const candidates = [record?.ts, record?.ts_start, record?.startedAt] + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.length > 0) return candidate + } + return null +} + +function limitDeep(value: unknown, depth: number): unknown { + if (value == null || typeof value === 'number' || typeof value === 'boolean') return value + if (typeof value === 'string') return truncateString(value, STRING_LIMIT) + if (depth <= 0) return summarize(value) + if (Array.isArray(value)) return value.slice(0, 20).map((item) => limitDeep(item, depth - 1)) + if (isRecord(value)) { + const out: Record = {} + for (const [key, child] of Object.entries(value).slice(0, 40)) out[key] = limitDeep(child, depth - 1) + return out + } + return String(value) +} + +function summarize(value: unknown): string { + if (Array.isArray(value)) return `[${value.length} items]` + if (isRecord(value)) return `{${Object.keys(value).length} keys}` + return truncateString(String(value), STRING_LIMIT) ?? '' +} + +function truncateString(value: unknown, max: number): string | undefined { + if (typeof value !== 'string') return undefined + return value.length <= max ? value : `${value.slice(0, max)}…` +} + +function callName(value: unknown): string | null { + if (!isRecord(value)) return null + if (typeof value.name === 'string') return value.name + const fn = isRecord(value.function) ? value.function : null + return typeof fn?.name === 'string' ? fn.name : null +} + +function compactObject(value: Record): Record { + return Object.fromEntries(Object.entries(value).filter(([, child]) => child !== undefined)) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts new file mode 100644 index 000000000..49ffe8892 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/poll-cli.ts @@ -0,0 +1,561 @@ +#!/usr/bin/env node +/** + * Standalone polling driver. No nanobot. Two-pass design for + * "typing-indicator" UX: + * + * PASS A — placeholders (deterministic, fast): + * For each newly-detected pending mention, post a short placeholder + * comment ("Working on this — back in a moment.") via seed-cli. The + * placeholder commentId is persisted in placeholders.jsonl so a + * crash between passes is recoverable. + * + * PASS B — finalisation (LLM, slower): + * For each placeholder not yet finalised, draft a reply via DeepSeek + * and replace the placeholder body via `seed-cli comment edit`. + * Mark the mention `replied`. + * + * On DeepSeek failure during Pass B the placeholder is edited to a + * short fallback message so it is never stuck on "Working…". + * + * The two-pass split means the user sees the agent reply within seconds + * (placeholder), even when the eventual answer takes longer to draft. + */ + +import {GovernanceCache} from './governance.js' +import {SeedCli} from './seedcli.js' +import {AuditRun} from './audit.js' +import {buildRedactor} from './redact.js' +import {loadConfig} from './config.js' +import {State, mentionKey} from './state.js' +import { + buildCommentMention, + buildThreadReplyMention, + commentEventCandidate, + detectThreadReplyToKm, + findKmMentionInComment, + buildReplyTarget, +} from './mentions.js' +import type {Mention, SeedComment} from './mentions.js' +import {bump, checkCap} from './limits.js' +import {draftReply, gatherCommentReplyContext} from './reply-engine.js' + +const ACTIVITY_LIMIT = 100 +const MAX_COMMENT_FETCHES = 200 +const PLACEHOLDER_BODY = 'Working on this — back in a moment. ⌛' +const FALLBACK_BODY = + 'I tried to draft a reply but hit a snag. Please rephrase or wait for the next cadence.' + +async function main(): Promise { + const config = loadConfig() + const redactor = buildRedactor() + const audit = new AuditRun({ + logsDir: config.logsDir, + trigger: process.env.KM_TRIGGER ?? 'poll-cli', + redactor, + seedSite: config.seedSite, + }) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'agent_start', + data: {seedServer: config.seedServer, seedSite: config.seedSite, mode: 'poll-cli'}, + }) + + let status: 'ok' | 'error' | 'denied' = 'ok' + try { + const cli = new SeedCli(config, redactor, audit) + const state = new State(config.stateDir) + const governance = new GovernanceCache(config, cli) + + if (config.useLocalDaemon) { + const writerArg = config.writerAid ? ['--writer', config.writerAid] : [] + const syncStatus = await cli.runRead(['site', 'sync-status', config.seedSite, ...writerArg]) + const parsed = syncStatus.parsedJson as {ready_for_writes?: boolean} | undefined + const ready = !!parsed?.ready_for_writes + audit.trace({ts: nowIso(), level: 'info', event: 'preflight_sync_status', data: {ready, output: parsed}}) + if (!ready) { + audit.trace({ts: nowIso(), level: 'warn', event: 'preflight_skipped', data: {reason: 'local-daemon-not-ready'}}) + audit.close({status: 'denied', logsDir: config.logsDir}) + await audit.flushTelemetry() + return + } + } + + const keyShow = await cli.runRead(['key', 'show', config.keyName]) + if (keyShow.exitCode !== 0) throw new Error(`key show failed: ${keyShow.stderr}`) + const kmAccountId = (keyShow.parsedJson as {accountId?: string} | undefined)?.accountId + if (!kmAccountId) throw new Error('Could not resolve agent accountId') + audit.meta.kmAccountId = kmAccountId + + const g = await governance.getGovernance(true) + audit.trace({ts: nowIso(), level: 'info', event: 'governance_loaded', data: {fetchedAt: g.fetchedAt}}) + + // TEMP: gate disabled by default — agent answers any commenter that mentions it. + // Set KM_ENFORCE_INVOKER_GATE=1 (or "true") to re-enable WRITER/allowlist enforcement. + const ENFORCE_INVOKER_GATE = /^(1|true|yes)$/i.test(process.env.KM_ENFORCE_INVOKER_GATE ?? '') + + // Resolve allowed-invokers (only when gate enforced). + const writers = new Set() + if (ENFORCE_INVOKER_GATE) { + if (g.rules.mentions.invokerSource === 'allowlist-doc') { + for (const a of g.allowlist.invokers) writers.add(a) + } else { + const caps = await cli.runRead(['account', 'capabilities', config.seedSite]) + const parsed = caps.parsedJson as {capabilities?: Array<{delegate?: string; role?: string}>} | undefined + for (const c of parsed?.capabilities ?? []) { + if (c.role === 'WRITER' && c.delegate) writers.add(c.delegate) + } + writers.add(config.seedSite.replace(/^hm:\/\//, '').split('/')[0]!) + } + audit.trace({ts: nowIso(), level: 'info', event: 'poll_collect_writers', data: {count: writers.size}}) + } else { + audit.trace({ + ts: nowIso(), + level: 'warn', + event: 'invoker_gate_disabled', + data: {note: 'replying to all authors; cap + blocked-list still active'}, + }) + } + + // ── PASS A: discover new mentions and post placeholders. ─────────────── + // + // Hyper.media's activity feed is eventually-consistent: new comment + // events frequently take minutes to surface, by which point a + // cursor-based walker has already advanced past the slot they would + // have occupied. We dropped the cursor and rely instead on + // `processed.jsonl` + `placeholders.jsonl` for idempotency. Each + // poll scans the last ACTIVITY_LIMIT events and fetches comment + // bodies up to MAX_COMMENT_FETCHES. + const actR = await cli.runRead(['activity', '--limit', String(ACTIVITY_LIMIT)]) + const events = ((actR.parsedJson as {events?: Array<{id?: string; type?: string; time?: string; author?: unknown}>}) + ?.events) ?? [] + let scanned = 0 + let placeholdersPosted = 0 + let skippedNotAllowed = 0 + let exhaustedBudget = false + // Thread-reply mentions deferred to a direct-reply pass (no placeholder). + // Workaround for seed-cli bug: `--reply` uses `parentComment.threadRoot` + // (RecordID) instead of `parentComment.threadRootVersion` (CID), so + // `CID.parse()` fails with "Non-base58btc character" for any parent that + // is itself a threaded reply. The placeholder→edit flow makes this worse + // because the edited placeholder becomes an ancestor with a threadRoot, + // breaking all subsequent `--reply` calls in the chain. Skipping the + // placeholder avoids introducing an edited comment into the chain. + // Upstream fix tracked in .ai/seed-cli-reply-chain-fix.md — once seed-cli + // is patched, thread-replies can use the placeholder→edit flow. + const deferredThreadReplies: Array<{mention: Mention; mid: string}> = [] + const blocked = new Set(g.rules.moderation.blockedAuthors) + const siteAccount = config.seedSite.replace(/^hm:\/\//, '').split('/')[0]! + + // Cache: commentAuthor → principal account that holds the writer cap. + // Seed accounts can `alias_account` to another account they act on behalf + // of (e.g. a device-key signs with its own id but is aliased to the + // user's main account). The writer-cap list keys on principals, so we + // resolve every comment author through this lookup before checking + // membership. + // + // Local daemon returns `account-not-found` when the author's account blob + // hasn't synced yet (common for accounts that have not posted to this + // site before). Fall back to the public gateway for the resolution only + // — we still read everything else from the local daemon. The gateway + // collapses alias chains in its response: querying for an aliased uid + // returns the principal's id directly. + const gatewayUrl = process.env.SEED_GATEWAY_URL ?? 'https://hyper.media' + const principalOf = new Map() + // Per-cycle cache for `comment get` lookups used by the thread-reply + // trigger. Sibling replies on the same thread re-walk the same + // ancestor chain, so caching here turns an O(depth × siblings) + // CLI-call cost into O(depth + siblings). + const replyChainCache = new Map() + const fetchCommentForChain = async (id: string): Promise => { + const r = await cli.runRead(['comment', 'get', id]) + if (r.exitCode !== 0 || !r.parsedJson) return null + return r.parsedJson as SeedComment + } + const resolvePrincipal = async (author: string): Promise => { + const cached = principalOf.get(author) + if (cached) return cached + // Local first. + const local = await cli.runRead(['account', 'get', author]) + const localAcct = + (local.parsedJson as + | {type?: string; aliasAccount?: string; alias_account?: string; id?: {uid?: string}} + | undefined) ?? {} + let principal: string | undefined + if (localAcct.type !== 'account-not-found') { + principal = localAcct.aliasAccount ?? localAcct.alias_account ?? localAcct.id?.uid + } + // Fall back to gateway if local has no record. + if (!principal) { + const gw = await cli.runRead(['-s', gatewayUrl, 'account', 'get', author]) + const gwAcct = + (gw.parsedJson as + | {type?: string; aliasAccount?: string; alias_account?: string; id?: {uid?: string}} + | undefined) ?? {} + if (gwAcct.type !== 'account-not-found') { + principal = gwAcct.aliasAccount ?? gwAcct.alias_account ?? gwAcct.id?.uid + } + } + const resolved = principal ?? author + principalOf.set(author, resolved) + return resolved + } + + for (const ev of events) { + if (scanned >= MAX_COMMENT_FETCHES) { + exhaustedBudget = true + break + } + const candidate = commentEventCandidate(ev as Parameters[0]) + if (!candidate) continue + scanned++ + const cr = await cli.runRead(['comment', 'get', candidate.commentId]) + if (cr.exitCode !== 0 || !cr.parsedJson) continue + const comment = cr.parsedJson as SeedComment + if (comment.targetAccount !== siteAccount) continue + if (comment.author === kmAccountId) continue + // Agent triggers on mentions of either itself or the site root — + // since the agent holds a WRITER capability on the site, mentions + // of the site (e.g. "@Develop Seed Hypermedia") are also addressed + // to it. + const evidence = findKmMentionInComment(comment, [kmAccountId, siteAccount]) + let mention: Mention | null = null + let threadReplyAncestor: string | undefined + if (evidence) { + mention = buildCommentMention(comment, evidence, candidate.ts) + } else if (comment.replyParent) { + // Second trigger path: comment is a reply (direct or transitive) + // inside a thread where KM has already commented. Lets multi-turn + // dialogue work without forcing the user to re-mention every time. + const hit = await detectThreadReplyToKm({ + comment, + kmAccountId, + fetchComment: fetchCommentForChain, + cache: replyChainCache, + }) + if (hit) { + mention = buildThreadReplyMention(comment, candidate.ts) + threadReplyAncestor = hit.ancestorCommentId + } + } + if (!mention) continue + if (blocked.has(mention.author)) continue + const mid = mentionKey(mention) + // Idempotency FIRST: a mention that's already been processed (even with + // status `not-allowed`) must not be re-classified each poll cycle. Doing + // so wrote thousands of duplicate "not-allowed" lines into + // processed.jsonl when an unprivileged author kept mentioning the agent. + if (state.isProcessed(mid) || state.hasPlaceholderFor(mid)) continue + + // Audit event for thread-reply trigger (after idempotency to avoid + // spamming the log every poll cycle for already-handled comments). + if (threadReplyAncestor) { + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'mention_via_thread_reply', + data: { + commentId: comment.id, + ancestorCommentId: threadReplyAncestor, + docId: mention.docId, + author: mention.author, + }, + }) + } + + if (ENFORCE_INVOKER_GATE) { + const principal = await resolvePrincipal(mention.author) + if (!writers.has(mention.author) && !writers.has(principal)) { + state.markProcessed(mention, audit.meta.runId, 'not-allowed') + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'mention_skipped_not_allowed', + data: {author: mention.author, principal, kind: mention.kind, docId: mention.docId}, + }) + skippedNotAllowed++ + continue + } + } + + // Per-day cap: counts whether it's a placeholder or direct reply. + const rs = state.getRateState() + const capCheck = checkCap(rs, 'comments', g.rules) + if (!capCheck.allowed) { + audit.trace({ + ts: nowIso(), + level: 'warn', + event: 'placeholder_skipped_cap', + data: {commentId: mention.commentId, reason: capCheck.reason}, + }) + break + } + + // Thread-reply mentions skip the placeholder→edit flow and are + // deferred to a direct-reply pass (see comment at deferredThreadReplies). + if (mention.triggerSource === 'thread-reply') { + deferredThreadReplies.push({mention, mid}) + state.setRateState(bump(rs, 'comments')) + continue + } + + const placeholderId = await postPlaceholder(cli, mention, audit) + if (!placeholderId) continue + state.recordPlaceholder({ + mentionId: mid, + placeholderId, + postedAt: nowIso(), + mention, + finalised: false, + }) + state.setRateState(bump(rs, 'comments')) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'placeholder_posted', + data: { + commentId: mention.commentId, + placeholderId, + docId: mention.docId, + textPreview: mention.text.replace(//g, ' ').slice(0, 200), + }, + }) + placeholdersPosted++ + } + + // ── PASS B: finalise placeholders (DeepSeek + comment edit). ─────────── + const pending = state.pendingPlaceholders() + let finalised = 0 + let errored = 0 + if (config.useStateMachine) { + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'state_machine_enabled', + data: {pending: pending.length}, + }) + // Drive each pending placeholder through the XState supervisor. The + // machine owns retry/backoff for the LLM call + comment edit and + // persists transitions to ${stateDir}/machines/.jsonl. + const {runMachinePassB} = await import('./machines/poll-driver.js') + const result = await runMachinePassB({ + config, + cli, + state, + audit, + pending, + siteAccount, + fallbackBody: FALLBACK_BODY, + }) + finalised = result.finalised + errored = result.errored + } else for (const rec of pending) { + // Per-run cap on comment edits is intentionally absent; we already + // counted each placeholder as a comment in Pass A, and `edit` does + // not produce a new top-level comment. + const question = rec.mention.text.replace(//g, ' ').trim() + const context = await gatherCommentReplyContext({ + cli, + mention: rec.mention, + siteAccount, + audit, + }) + const reply = await draftReply(question, context, audit) + const body = reply ?? FALLBACK_BODY + const r = await cli.runWrite(['comment', 'edit', rec.placeholderId, '--body', body]) + if (r.exitCode === 0) { + state.finalisePlaceholder(rec.mentionId, rec.placeholderId) + state.markProcessed(rec.mention, audit.meta.runId, reply ? 'replied' : 'error') + audit.trace({ + ts: nowIso(), + level: 'info', + event: reply ? 'reply_finalised' : 'reply_finalised_with_fallback', + data: { + commentId: rec.mention.commentId, + placeholderId: rec.placeholderId, + replyPreview: body.slice(0, 200), + }, + }) + finalised++ + } else { + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'reply_edit_failed', + data: { + commentId: rec.mention.commentId, + placeholderId: rec.placeholderId, + exitCode: r.exitCode, + stderr: r.stderr.slice(0, 200), + }, + }) + errored++ + } + } + + // ── PASS C: direct replies for thread-reply mentions (no placeholder). ── + // + // Thread-reply mentions skip the placeholder→edit dance to avoid + // inserting an edited comment into the reply chain. seed-cli's + // `--reply` breaks when the parent chain contains an edited comment + // (uses threadRoot RecordID instead of CID). We draft the full reply + // first, then post it as a single `comment create`. + // + // Upstream fix: .ai/seed-cli-reply-chain-fix.md — once seed-cli is + // patched, this pass can be removed and thread-replies can rejoin the + // placeholder→edit flow in Pass A/B. + let directReplied = 0 + for (const {mention} of deferredThreadReplies) { + const question = mention.text.replace(//g, ' ').trim() + const context = await gatherCommentReplyContext({cli, mention, siteAccount, audit}) + const reply = await draftReply(question, context, audit) + const body = reply ?? FALLBACK_BODY + const target = buildReplyTarget(mention) + const argv = ['comment', 'create', target.targetId, '--body', body] + if (target.replyTo) argv.push('--reply', target.replyTo) + let r = await cli.runWrite(argv) + // Same seed-cli fallback as postPlaceholder: if --reply fails on + // a threaded parent, drop to a top-level comment. + if (r.exitCode !== 0 && target.replyTo && /non-base58btc/i.test(r.stderr)) { + audit.trace({ + ts: nowIso(), + level: 'warn', + event: 'direct_reply_threading_fallback', + data: {commentId: mention.commentId, replyTo: target.replyTo, stderr: r.stderr.slice(0, 200)}, + }) + r = await cli.runWrite(['comment', 'create', target.targetId, '--body', body]) + } + if (r.exitCode === 0) { + state.markProcessed(mention, audit.meta.runId, reply ? 'replied' : 'error') + audit.trace({ + ts: nowIso(), + level: 'info', + event: reply ? 'direct_reply_posted' : 'direct_reply_posted_with_fallback', + data: { + commentId: mention.commentId, + docId: mention.docId, + replyPreview: body.slice(0, 200), + }, + }) + directReplied++ + } else { + state.markProcessed(mention, audit.meta.runId, 'error') + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'direct_reply_failed', + data: { + commentId: mention.commentId, + exitCode: r.exitCode, + stderr: r.stderr.slice(0, 200), + }, + }) + errored++ + } + } + + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'poll_done', + data: { + events: events.length, + scanned, + placeholdersPosted, + skippedNotAllowed, + finalised, + directReplied, + errored, + exhaustedBudget, + }, + }) + } catch (err) { + status = 'error' + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'poll_fatal', + data: {message: err instanceof Error ? err.message : String(err)}, + }) + } finally { + audit.trace({ts: nowIso(), level: 'info', event: 'agent_end', data: {status}}) + audit.close({status, logsDir: config.logsDir}) + await audit.flushTelemetry() + } +} + +function nowIso(): string { + return new Date().toISOString() +} + +/** + * Posts a placeholder comment for a mention. Returns the canonical + * comment record id (`/`) on success, or null on failure. + * + * seed-cli's `comment create` emits "✓ Comment published: " to + * STDERR (not stdout) and the value is the version CID, not the record + * id. We parse the CID, then call `comment get ` to read back the + * full comment record and return its `id` field. + */ +async function postPlaceholder(cli: SeedCli, mention: Mention, audit: AuditRun): Promise { + const target = buildReplyTarget(mention) + const baseArgv = ['comment', 'create', target.targetId, '--body', PLACEHOLDER_BODY] + // Try threaded reply first. + let r = await cli.runWrite(target.replyTo ? [...baseArgv, '--reply', target.replyTo] : baseArgv) + // Known seed-cli quirk: `--reply` fails with "Non-base58btc character" + // when the parent comment chain includes an edited comment. Fall back + // to a top-level reply on the same doc — not threaded but functional. + if (r.exitCode !== 0 && target.replyTo && /non-base58btc/i.test(r.stderr)) { + audit.trace({ + ts: nowIso(), + level: 'warn', + event: 'placeholder_reply_fallback', + data: {commentId: mention.commentId, parentReplyTo: target.replyTo, stderr: r.stderr.slice(0, 200)}, + }) + r = await cli.runWrite(baseArgv) + } + if (r.exitCode !== 0) { + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'placeholder_post_failed', + data: {commentId: mention.commentId, exitCode: r.exitCode, stderr: r.stderr.slice(0, 200)}, + }) + return null + } + const cid = extractCidFromOutput(r.stdout, r.stderr) + if (!cid) { + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'placeholder_cid_parse_failed', + data: {commentId: mention.commentId, stdoutPreview: r.stdout.slice(0, 200), stderrPreview: r.stderr.slice(0, 200)}, + }) + return null + } + // Resolve CID → canonical comment record id. + const get = await cli.runRead(['comment', 'get', cid]) + if (get.exitCode !== 0) { + audit.trace({ + ts: nowIso(), + level: 'error', + event: 'placeholder_resolve_failed', + data: {commentId: mention.commentId, cid, exitCode: get.exitCode, stderr: get.stderr.slice(0, 200)}, + }) + return null + } + const parsed = get.parsedJson as {id?: string} | undefined + return parsed?.id ?? null +} + +function extractCidFromOutput(stdout: string, stderr: string): string | null { + const combined = `${stdout}\n${stderr}` + const m = combined.match(/comment\s+published:\s*(bafy[\w]+)/i) + return m?.[1] ?? null +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('km-poll fatal:', err) + process.exit(1) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.test.ts new file mode 100644 index 000000000..297e76779 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.test.ts @@ -0,0 +1,28 @@ +import {describe, expect, it} from 'bun:test' +import {buildRedactor} from './redact.js' + +describe('buildRedactor', () => { + it('redacts known secrets', () => { + const r = buildRedactor({DEEPSEEK_API_KEY: 'sk-aaaaaaaaaaaa'} as NodeJS.ProcessEnv) + expect(r('hello sk-aaaaaaaaaaaa world')).toBe('hello ***REDACTED*** world') + }) + + it('redacts longest first to avoid partial overlaps', () => { + const r = buildRedactor({ + DEEPSEEK_API_KEY: 'sk-long-secret-12345', + OPENAI_API_KEY: 'sk-shorter', + } as NodeJS.ProcessEnv) + const out = r('value=sk-long-secret-12345 other=sk-shorter') + expect(out).toBe('value=***REDACTED*** other=***REDACTED***') + }) + + it('passes through when no secrets configured', () => { + const r = buildRedactor({}) + expect(r('plain string')).toBe('plain string') + }) + + it('ignores empty / short values', () => { + const r = buildRedactor({DEEPSEEK_API_KEY: 'short'} as NodeJS.ProcessEnv) + expect(r('contains short value')).toBe('contains short value') + }) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.ts new file mode 100644 index 000000000..cf3315ba0 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/redact.ts @@ -0,0 +1,39 @@ +/** + * Redacts secret values from arbitrary strings. Built once at startup from + * env-var values that look like secrets (tokens, API keys, mnemonics). + * + * Anything ≥ 8 characters that matches a known secret env var is replaced + * with `***REDACTED***`. JSON / log output is post-processed through this + * before being persisted, so the run dir never contains raw secrets. + */ + +export type Redactor = (input: string) => string + +const SECRET_ENV_KEYS = [ + 'DEEPSEEK_API_KEY', + 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', + 'TELEGRAM_TOKEN', + 'KM_MNEMONIC', +] + +export function buildRedactor(env: NodeJS.ProcessEnv = process.env): Redactor { + const needles: string[] = [] + for (const key of SECRET_ENV_KEYS) { + const v = env[key] + if (typeof v === 'string' && v.length >= 8) needles.push(v) + } + // Deduplicate and sort longest-first so substring matches don't break + // longer secrets. + const unique = Array.from(new Set(needles)).sort((a, b) => b.length - a.length) + if (unique.length === 0) return (s) => s + return (input) => { + let out = input + for (const n of unique) { + if (n && out.includes(n)) { + out = out.split(n).join('***REDACTED***') + } + } + return out + } +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts new file mode 100644 index 000000000..46369d6ab --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/reply-engine.ts @@ -0,0 +1,345 @@ +/** + * Shared reply pipeline: search the community site for relevant docs, + * inject as context, ask DeepSeek for a grounded answer. Used by both + * the polling driver (replies to comment mentions) and the Telegram bot + * (replies to operator queries). + */ + +import type {SeedCli} from './seedcli.js' +import type {AuditRun} from './audit.js' +import type {Mention, SeedComment} from './mentions.js' + +const TOP_K = 5 +const PER_DOC_CHARS = 600 + +const COMMUNITY_SYSTEM_PROMPT = + `You are the Knowledge Manager — a moderator of a Seed Hypermedia community. ` + + `Answer the user's question grounded in the community's own documents whenever possible. ` + + `When you reference a document, embed its full hm:// URL inline as a markdown link, e.g. [Title](hm://...). ` + + `If the community context below is empty or doesn't cover the question, answer from your general knowledge in one sentence and explicitly say "I couldn't find this in our community's docs" so the asker knows. ` + + `If the comment thread context shows that you (the Knowledge Manager) already replied earlier in this thread, treat the new comment as a follow-up turn: do not re-introduce yourself, do not repeat earlier answers verbatim, and respond conversationally. ` + + `Plain text or simple markdown only. No headers, no code fences, no greeting/signoff. Stay under 120 words.` + +const SYSTEM_INSPECTOR_PROMPT = + `You are the Knowledge Manager bot answering an OPERATOR question about your own implementation, configuration, and recent activity. ` + + `Use the system context blocks below to ground every claim. ` + + `If you don't know, say so plainly. Never make up paths, services, or commands. ` + + `Answer concisely (≤200 words). Plain text or simple markdown. Reference filenames or systemd units explicitly when relevant.` + +export type ChatTurn = {role: 'user' | 'assistant'; content: string} + +/** + * Builds the full context block used when answering a comment mention. + * Order (most-relevant first): + * 1. Parent document the comment was posted on (full body). + * 2. Comment thread (replyParent chain → root, plus the asker's comment). + * 3. Linked documents/profiles cited in the parent doc or any thread + * comment (1-hop). Both `seed-cli document get` and `account get` + * are tried; non-resolvable links are dropped. + * 4. Site-search hits relevant to the question text — keyword match + * only (Seed search is currently keyword-based, not semantic, so + * we send the raw question and let the LLM use whatever lands). + * + * No per-doc truncation — operator chose "no cap for now". DeepSeek's + * 128K context window absorbs realistic site sizes. + */ +export async function gatherCommentReplyContext(opts: { + cli: SeedCli + mention: Mention + siteAccount: string + audit?: AuditRun +}): Promise { + const {cli, mention, siteAccount, audit} = opts + const sections: string[] = [] + const seenLinks = new Set() + + // 1. Parent document. + const parentBody = await fetchDocOrProfile(cli, mention.docId) + if (parentBody) { + sections.push(`### Parent document — ${mention.docId}\n${parentBody}`) + collectHmLinks(parentBody, seenLinks) + } + + // 2. Comment thread (walk replyParent chain UP, capped at 30 hops). + const threadComments = await walkThread(cli, mention.commentId) + if (threadComments.length > 0) { + const renderedThread = threadComments + .map((c, i) => `(#${i + 1}) ${c.author}\n${commentText(c)}`) + .join('\n\n') + sections.push(`### Comment thread (oldest → newest)\n${renderedThread}`) + for (const c of threadComments) { + collectHmLinksFromComment(c, seenLinks) + } + } + + // 3. Linked documents cited in the parent doc and thread (1-hop). + // Avoid re-fetching the parent doc itself + the agent's own profile + // (would just be a self-reference). + const linksToFetch = Array.from(seenLinks).filter((href) => { + const stripped = stripVersionAndBlock(href) + return stripped !== stripVersionAndBlock(mention.docId) + }) + const linkedSections: string[] = [] + for (const href of linksToFetch) { + const body = await fetchDocOrProfile(cli, href) + if (body) linkedSections.push(`### Linked — ${href}\n${body}`) + } + if (linkedSections.length > 0) { + sections.push(linkedSections.join('\n\n')) + } + + // 4. Site search (keyword) for the asker's question text. + const search = await gatherSiteContext(cli, plainText(mention.text), siteAccount, audit) + if (search) sections.push(search) + + audit?.trace({ + ts: nowIso(), + level: 'info', + event: 'reply_context_built', + data: { + parentDocBytes: parentBody.length, + threadComments: threadComments.length, + linkedDocs: linkedSections.length, + hasSearch: Boolean(search), + }, + }) + + return sections.join('\n\n') +} + +/** + * Tries `document get` first, then `account get` (for hm:// + * profile links). Returns the markdown body or empty string. + */ +async function fetchDocOrProfile(cli: SeedCli, hmUrl: string): Promise { + const stripped = stripVersionAndBlock(hmUrl) + const dr = await cli.runRead(['document', 'get', stripped]).catch(() => ({exitCode: -1, stdout: '', stderr: '', parsedJson: undefined as unknown})) + if (dr.exitCode === 0 && dr.stdout) { + return dr.stdout.replace(//g, '').trim() + } + // Account profile fallback. + const accountUid = extractAccountUid(stripped) + if (accountUid) { + const ar = await cli.runRead(['account', 'get', accountUid]).catch(() => ({exitCode: -1, stdout: '', stderr: '', parsedJson: undefined as unknown})) + if (ar.exitCode === 0 && ar.parsedJson) { + const meta = (ar.parsedJson as {metadata?: {name?: string; summary?: string; icon?: string}}).metadata ?? {} + if (meta.name || meta.summary) { + const lines = [`(profile metadata)`] + if (meta.name) lines.push(`name: ${meta.name}`) + if (meta.summary) lines.push(`summary: ${meta.summary}`) + return lines.join('\n') + } + } + } + return '' +} + +async function walkThread(cli: SeedCli, startCommentId: string | undefined): Promise { + if (!startCommentId) return [] + const out: SeedComment[] = [] + let cur: string | undefined = startCommentId + for (let i = 0; i < 30 && cur; i++) { + const r = await cli.runRead(['comment', 'get', cur]).catch(() => ({exitCode: -1, parsedJson: undefined as unknown})) + if (r.exitCode !== 0 || !r.parsedJson) break + const c = r.parsedJson as SeedComment & {replyParent?: string} + out.unshift(c) + cur = (c.replyParent && c.replyParent.trim()) || undefined + } + return out +} + +function commentText(c: SeedComment): string { + const lines: string[] = [] + for (const item of c.content ?? []) { + if (item.block?.text) lines.push(item.block.text.replace(//g, '@…')) + } + return lines.join('\n') +} + +function collectHmLinks(text: string, into: Set): void { + // Match hm:// URLs in markdown body (any prefix, may include path, + // version, block fragment). + const re = /hm:\/\/[A-Za-z0-9._~/?#&=:%-]+/g + for (const m of text.matchAll(re)) into.add(m[0]) +} + +function collectHmLinksFromComment(c: SeedComment, into: Set): void { + for (const item of c.content ?? []) { + if (!item.block) continue + if (item.block.text) collectHmLinks(item.block.text, into) + for (const ann of item.block.annotations ?? []) { + if (typeof ann.link === 'string' && ann.link.startsWith('hm://')) into.add(ann.link) + } + } +} + +function plainText(s: string): string { + return s.replace(//g, ' ').trim() +} + +function stripVersionAndBlock(hmUrl: string): string { + return hmUrl.split('?')[0]!.split('#')[0]! +} + +function extractAccountUid(hmUrl: string): string | undefined { + const m = hmUrl.match(/^hm:\/\/([^/?#]+)/) + return m?.[1] +} + +type SearchHit = {hmUrl: string; title?: string} + +export async function gatherSiteContext( + cli: SeedCli, + question: string, + siteAccount: string, + audit?: AuditRun, +): Promise { + const sr = await cli.runRead(['search', question, '-a', siteAccount]) + if (sr.exitCode !== 0 || !sr.parsedJson) { + audit?.trace({ts: nowIso(), level: 'warn', event: 'site_context_search_failed', data: {exitCode: sr.exitCode}}) + return '' + } + type RawHit = {id?: string | {id?: string}; title?: string} + const raw = + (sr.parsedJson as {entities?: RawHit[]; results?: RawHit[]}).entities ?? + (sr.parsedJson as {results?: RawHit[]}).results ?? + [] + const top: SearchHit[] = raw + .map((h): SearchHit | null => { + const id = h.id + if (typeof id === 'string') return {hmUrl: id, title: h.title} + if (id && typeof id === 'object' && typeof id.id === 'string') return {hmUrl: id.id, title: h.title} + return null + }) + .filter((x): x is SearchHit => x !== null) + .slice(0, TOP_K) + if (top.length === 0) { + audit?.trace({ts: nowIso(), level: 'info', event: 'site_context_empty', data: {question: question.slice(0, 200)}}) + return '' + } + const sections: string[] = [] + for (let i = 0; i < top.length; i++) { + const hit = top[i]! + const dr = await cli.runRead(['document', 'get', hit.hmUrl]) + if (dr.exitCode !== 0) continue + const body = dr.stdout.replace(//g, '').slice(0, PER_DOC_CHARS).trim() + sections.push(`${i + 1}. ${hit.title ?? '(untitled)'} — ${hit.hmUrl}\n${body}`) + } + audit?.trace({ + ts: nowIso(), + level: 'info', + event: 'site_context_collected', + data: {hits: raw.length, used: sections.length, urls: top.map((t) => t.hmUrl)}, + }) + if (sections.length === 0) return '' + return `## Community context (relevant documents found in this site)\n${sections.join('\n\n')}` +} + +export async function draftReply( + question: string, + siteContext: string, + audit?: AuditRun, + history: ChatTurn[] = [], +): Promise { + const userMsg = siteContext + ? `Question: ${question}\n\n${siteContext}` + : `Question: ${question}\n\n## Community context\n(no relevant documents found in the community for this query)` + return callDeepSeek( + [ + {role: 'system', content: COMMUNITY_SYSTEM_PROMPT}, + ...history, + {role: 'user', content: userMsg}, + ], + {audit, maxTokens: 400}, + ) +} + +/** + * Operator-facing reply. Used by Telegram `/ask`. The caller assembles + * a system-context blob (README + recent runs + governance) and passes + * it inline alongside the question. No site search. Multi-turn aware. + */ +export async function draftSystemReply( + question: string, + systemContext: string, + audit?: AuditRun, + history: ChatTurn[] = [], +): Promise { + const userMsg = `Operator question: ${question}\n\n## System context\n${systemContext}` + return callDeepSeek( + [ + {role: 'system', content: SYSTEM_INSPECTOR_PROMPT}, + ...history, + {role: 'user', content: userMsg}, + ], + {audit, maxTokens: 600, temperature: 0.2}, + ) +} + +async function callDeepSeek( + messages: Array<{role: 'system' | 'user' | 'assistant'; content: string}>, + opts: {audit?: AuditRun; maxTokens?: number; temperature?: number}, +): Promise { + const audit = opts.audit + const apiKey = process.env.DEEPSEEK_API_KEY + if (!apiKey) { + audit?.trace({ts: nowIso(), level: 'error', event: 'deepseek_no_key'}) + return null + } + const body = JSON.stringify({ + model: 'deepseek-chat', + messages, + temperature: opts.temperature ?? 0.4, + max_tokens: opts.maxTokens ?? 400, + }) + const t0 = Date.now() + let res: Response + try { + res = await fetch('https://api.deepseek.com/v1/chat/completions', { + method: 'POST', + headers: {'content-type': 'application/json', authorization: `Bearer ${apiKey}`}, + body, + }) + } catch (err) { + audit?.trace({ + ts: nowIso(), + level: 'error', + event: 'deepseek_network_error', + data: {message: err instanceof Error ? err.message : String(err)}, + }) + return null + } + const latencyMs = Date.now() - t0 + if (!res.ok) { + const text = await res.text().catch(() => '') + audit?.trace({ + ts: nowIso(), + level: 'error', + event: 'deepseek_http_error', + data: {status: res.status, body: text.slice(0, 300), latencyMs}, + }) + return null + } + const json = (await res.json()) as { + choices?: Array<{message?: {content?: string}}> + usage?: {prompt_tokens?: number; completion_tokens?: number; total_tokens?: number} + } + const reply = json.choices?.[0]?.message?.content?.trim() + audit?.llm({ + ts_start: new Date(t0).toISOString(), + ts_end: nowIso(), + latency_ms: latencyMs, + model: 'deepseek-chat', + completion: reply ?? '', + usage: { + prompt: json.usage?.prompt_tokens, + completion: json.usage?.completion_tokens, + total: json.usage?.total_tokens, + }, + }) + return reply ?? null +} + +function nowIso(): string { + return new Date().toISOString() +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seed-primer.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seed-primer.ts new file mode 100644 index 000000000..8122af176 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seed-primer.ts @@ -0,0 +1,32 @@ +/** + * Shared "how Seed's markdown works" primer that gets injected into every + * LLM system prompt the agent generates content for. Without this, the + * model emits bare hm:// URLs that don't render as links/embeds, or + * wraps lists inside extra Paragraph parents because of leading prose. + * + * Keep it short — every cadence/agent prompt pays for these tokens. + */ +export const SEED_MARKDOWN_PRIMER = `## Seed Hypermedia markdown primer (READ THIS BEFORE WRITING) + +You are writing for Seed Hypermedia, which uses a constrained Markdown. +Follow these rules exactly — non-conforming output will be rendered as broken text: + +1. **Inline links to docs** — use \`[Title](hm://...)\` — NEVER paste a bare hm:// URL in prose; it will render as raw text, not a link. +2. **Embeds / mention chips** — use autolink syntax \`\` on its own to insert a navigable chip. Use this when the reference IS the content (e.g. a "Recommended reading" item, an author chip). +3. **Account mentions** — use \`>\` (autolink). This renders as a person chip. +4. **Lists must not have a EMPTY wrapping intro paragraph.** A heading is itself the list's parent. Do NOT write: + - WRONG: + \`\`\` + ## Decisions + + - decision 1 + \`\`\` + - RIGHT: + \`\`\` + ## Decisions + - decision 1 + \`\`\` +5. **Cite every reference** — if you name a doc, person, or thread, include an inline link \`[Title](hm://...)\` or an embed \`\`. No uncited claims. +6. **Headings start at H2 (##).** Do not use H1 — the document title is injected separately as metadata. +7. **No code fences around the whole document.** Emit raw Markdown. +` diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.test.ts new file mode 100644 index 000000000..4da8df91c --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.test.ts @@ -0,0 +1,29 @@ +import {describe, expect, it} from 'bun:test' +import {isDenied} from './seedcli.js' + +describe('isDenied', () => { + it('blocks key mutations', () => { + expect(isDenied('key', 'generate')).toBe(true) + expect(isDenied('key', 'remove')).toBe(true) + expect(isDenied('key', 'import')).toBe(true) + expect(isDenied('key', 'rename')).toBe(true) + }) + + it('allows key reads (needed at boot for accountId resolution)', () => { + expect(isDenied('key', 'list')).toBe(false) + expect(isDenied('key', 'show')).toBe(false) + expect(isDenied('key', 'default')).toBe(false) + expect(isDenied('key', 'derive')).toBe(false) + }) + + it('blocks capability mutations', () => { + expect(isDenied('capability', 'create')).toBe(true) + }) + + it('allows ordinary read commands', () => { + expect(isDenied('document', 'get')).toBe(false) + expect(isDenied('comment', 'list')).toBe(false) + expect(isDenied('search', '')).toBe(false) + expect(isDenied('activity', '')).toBe(false) + }) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.ts new file mode 100644 index 000000000..539e04a32 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/seedcli.ts @@ -0,0 +1,139 @@ +/** + * Thin typed wrapper around `seed-cli`. Every invocation is recorded in + * `seed-cli.jsonl` of the current audit run with full argv, exit code, + * and (truncated) stdout/stderr. + * + * Hard denylist: certain subcommands are refused unconditionally to keep + * the rules doc from being able to weaken security. Writes always force + * `--key ` and `-s `. + */ + +import {spawn} from 'node:child_process' +import type {AgentConfig} from './config.js' +import type {AuditRun} from './audit.js' +import type {Redactor} from './redact.js' + +const STDOUT_TRUNCATE_BYTES = 64 * 1024 + +/** + * Hardcoded denylist of `:` pairs that are NEVER + * permitted — even if the rules doc tries to enable them. Read-only key + * operations (`key list`, `key show`, `key default`, `key derive`) are + * allowed because the wrapper itself needs them at boot to resolve the + * agent's accountId. + */ +const DENY_VERB_PAIRS = new Set([ + // Anything that mutates the keystore. + 'key:generate', + 'key:import', + 'key:remove', + 'key:rename', + // Anything that mutates the capability graph. + 'capability:create', + // Account profile mutations are owner-only. + 'account:set', + 'account:remove', +]) + +export class SeedCliError extends Error { + readonly code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } +} + +export type SeedCliResult = { + exitCode: number + stdout: string + stderr: string + parsedJson?: unknown +} + +export class SeedCli { + constructor( + private readonly config: AgentConfig, + private readonly redactor: Redactor, + private readonly audit?: AuditRun, + ) {} + + /** Read-only commands. No --key injection. */ + async runRead(args: string[]): Promise { + return this.run(args, {requireKey: false}) + } + + /** + * Write commands. Forces `--key ` and `-s `. Refuses anything + * in the deny list before spawning. + */ + async runWrite(args: string[]): Promise { + return this.run(args, {requireKey: true}) + } + + private async run(args: string[], opts: {requireKey: boolean}): Promise { + if (args.length === 0) { + throw new SeedCliError('EMPTY_ARGS', 'seed-cli invoked with no arguments') + } + const pair = `${args[0] ?? ''}:${args[1] ?? ''}` + if (DENY_VERB_PAIRS.has(pair)) { + throw new SeedCliError('DENIED_SUBCOMMAND', `seed-cli "${pair}" is denied by hardcoded policy`) + } + const finalArgs: string[] = ['-s', this.config.seedServer, ...args] + if (opts.requireKey && !args.includes('--key') && !args.includes('-k')) { + finalArgs.push('--key', this.config.keyName) + } + const tsStart = new Date().toISOString() + const t0 = Date.now() + const {exitCode, stdout, stderr} = await spawnCapture(this.config.cliPath, finalArgs) + const tsEnd = new Date().toISOString() + const latencyMs = Date.now() - t0 + if (this.audit) { + this.audit.seedCli({ + ts_start: tsStart, + ts_end: tsEnd, + latency_ms: latencyMs, + argv: [this.config.cliPath, ...finalArgs], + exit_code: exitCode, + stdout: this.redactor(truncate(stdout)), + stderr: this.redactor(truncate(stderr)), + }) + } + let parsedJson: unknown + if (stdout.trim().startsWith('{') || stdout.trim().startsWith('[')) { + try { + parsedJson = JSON.parse(stdout) + } catch { + /* not JSON, ignore */ + } + } + return {exitCode, stdout, stderr, parsedJson} + } +} + +function truncate(s: string): string { + if (Buffer.byteLength(s) <= STDOUT_TRUNCATE_BYTES) return s + return s.slice(0, STDOUT_TRUNCATE_BYTES) + `\n…[truncated]` +} + +function spawnCapture(cmd: string, args: string[]): Promise<{exitCode: number; stdout: string; stderr: string}> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: {...process.env}, + }) + let stdout = '' + let stderr = '' + child.stdout?.on('data', (d) => (stdout += d.toString())) + child.stderr?.on('data', (d) => (stderr += d.toString())) + child.on('error', reject) + child.on('close', (code) => { + resolve({exitCode: typeof code === 'number' ? code : -1, stdout, stderr}) + }) + }) +} + +// Test helper: split out the deny-list check so unit tests can exercise it +// without a real CLI binary on disk. +export function isDenied(command: string, subcommand: string): boolean { + return DENY_VERB_PAIRS.has(`${command}:${subcommand}`) +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.test.ts new file mode 100644 index 000000000..c7c07c362 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.test.ts @@ -0,0 +1,58 @@ +import {describe, expect, it, beforeEach, afterEach} from 'bun:test' +import {mkdtempSync, rmSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {State, mentionKey} from './state.js' +import type {Mention} from './mentions.js' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'km-state-')) +}) + +afterEach(() => { + if (dir && dir !== '/') rmSync(dir, {recursive: true, force: true}) +}) + +const sampleMention: Mention = { + kind: 'comment', + docId: 'hm://site/doc', + commentId: 'bafy1', + author: 'z6Mkauthor', + text: '@[KM](hm://z6Mkx) hi', + ts: '2026-05-05T00:00:00Z', +} + +describe('State.cursor', () => { + it('round-trips', () => { + const s = new State(dir) + expect(s.getCursor()).toBeNull() + s.setCursor('tok-1') + expect(s.getCursor()).toBe('tok-1') + s.setCursor('tok-2') + expect(s.getCursor()).toBe('tok-2') + }) +}) + +describe('State.inbox', () => { + it('FIFO pop', () => { + const s = new State(dir) + s.enqueue(sampleMention) + s.enqueue({...sampleMention, commentId: 'bafy2'}) + expect(s.inboxSize()).toBe(2) + expect(s.popFromInbox()?.commentId).toBe('bafy1') + expect(s.popFromInbox()?.commentId).toBe('bafy2') + expect(s.popFromInbox()).toBeNull() + }) +}) + +describe('State.processed', () => { + it('idempotency: enqueue skips already processed', () => { + const s = new State(dir) + s.markProcessed(sampleMention, 'run-1', 'replied') + expect(s.isProcessed(mentionKey(sampleMention))).toBe(true) + s.enqueue(sampleMention) + expect(s.inboxSize()).toBe(0) + }) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.ts new file mode 100644 index 000000000..8f569f5f2 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/state.ts @@ -0,0 +1,228 @@ +/** + * Ephemeral runtime state on disk: + * + * activity-cursor.json — last activity event token consumed by the poller + * inbox.jsonl — pending mentions queued for processing + * processed.jsonl — mentions already answered (idempotency) + * rate-counters.json — per-day / per-run counters (limits.ts) + * + * Files live under `${stateDir}` (default `/home/km/km-state`). All writes + * go through `O_APPEND` (jsonl) or atomic rename (json) so a crash never + * corrupts state. Wrapper exposes inbox_pop / inbox_mark_done / cursor_* + * tools to the LLM so the orchestration loop is observable but state + * mutation is controlled. + */ + +import {appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs' +import {join} from 'node:path' +import type {Mention} from './mentions.js' +import type {RateState} from './limits.js' +import {newRateState} from './limits.js' + +const CURSOR_FILE = 'activity-cursor.json' +const INBOX_FILE = 'inbox.jsonl' +const PROCESSED_FILE = 'processed.jsonl' +const RATE_FILE = 'rate-counters.json' +const PLACEHOLDERS_FILE = 'placeholders.jsonl' + +/** + * Pending placeholder reply: a comment we already posted on Seed but + * have not yet replaced with the final answer. Used by the two-pass + * polling loop (placeholder first, then DeepSeek + edit). Survives + * process crashes. + */ +export type PlaceholderRecord = { + mentionId: string // mentionKey(...) + placeholderId: string // commentId returned by `comment create` + postedAt: string + /** Original mention payload — kept so the next run can build a reply + * without re-fetching/re-classifying the source comment. */ + mention: import('./mentions.js').Mention + /** Whether the placeholder has been replaced via `comment edit`. */ + finalised: boolean +} + +export class State { + constructor(private readonly stateDir: string) { + if (!existsSync(stateDir)) mkdirSync(stateDir, {recursive: true, mode: 0o700}) + } + + // ─── cursor ──────────────────────────────────────────────────────────────── + // + // We track the latest activity-event id we've already classified. Each + // poll fetches the first page of activity (newest-first) and stops as + // soon as it sees this id. Stored as a small JSON blob for forward + // compatibility (future: track per-resource cursors). + + getCursor(): string | null { + return this.readJson<{lastEventId?: string} | null>(CURSOR_FILE, null)?.lastEventId ?? null + } + + setCursor(eventId: string): void { + this.writeJsonAtomic(CURSOR_FILE, {lastEventId: eventId, ts: new Date().toISOString()}) + } + + // ─── inbox ───────────────────────────────────────────────────────────────── + + enqueue(mention: Mention): void { + if (this.isProcessed(mentionKey(mention))) return + appendFileSync(join(this.stateDir, INBOX_FILE), JSON.stringify(mention) + '\n') + } + + /** Returns and removes the oldest queued mention, if any. */ + popFromInbox(): Mention | null { + const path = join(this.stateDir, INBOX_FILE) + if (!existsSync(path)) return null + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean) + if (lines.length === 0) return null + const first = lines.shift()! + writeFileSync(path, lines.length ? lines.join('\n') + '\n' : '', {mode: 0o600}) + try { + return JSON.parse(first) as Mention + } catch { + return null + } + } + + inboxSize(): number { + const path = join(this.stateDir, INBOX_FILE) + if (!existsSync(path)) return 0 + return readFileSync(path, 'utf-8').split('\n').filter(Boolean).length + } + + // ─── processed (idempotency) ─────────────────────────────────────────────── + + markProcessed(mention: Mention, runId: string, status: 'replied' | 'not-allowed' | 'error'): void { + const record = {key: mentionKey(mention), runId, status, ts: new Date().toISOString()} + appendFileSync(join(this.stateDir, PROCESSED_FILE), JSON.stringify(record) + '\n') + } + + isProcessed(key: string): boolean { + const path = join(this.stateDir, PROCESSED_FILE) + if (!existsSync(path)) return false + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean) + for (const line of lines) { + try { + const r = JSON.parse(line) as {key?: string} + if (r.key === key) return true + } catch { + /* skip */ + } + } + return false + } + + // ─── rate counters ───────────────────────────────────────────────────────── + + /** + * Returns the persisted per-day counters, plus an empty per-run counter + * map. `perRun` is by definition scoped to the current process — we + * never restore it from disk. Storing it on disk would conflate + * separate invocations and turn a "max per run" limit into a "max ever" + * limit, which is what we used to do (bug). + */ + getRateState(): RateState { + const persisted = this.readJson(RATE_FILE, newRateState()) + return {...persisted, perRun: {}} + } + + /** + * Persists only the per-day portion. `perRun` is dropped on write. + */ + setRateState(state: RateState): void { + this.writeJsonAtomic(RATE_FILE, {day: state.day, perDay: state.perDay, perRun: {}}) + } + + // ─── placeholders (typing-indicator) ─────────────────────────────────────── + + /** + * Record a freshly-posted placeholder. Append-only so a crash never + * leaves us unsure whether the comment was created. + */ + recordPlaceholder(rec: PlaceholderRecord): void { + appendFileSync(join(this.stateDir, PLACEHOLDERS_FILE), JSON.stringify(rec) + '\n') + } + + /** All placeholders that haven't been finalised yet. */ + pendingPlaceholders(): PlaceholderRecord[] { + const path = join(this.stateDir, PLACEHOLDERS_FILE) + if (!existsSync(path)) return [] + const out: PlaceholderRecord[] = [] + // Walk backwards so the latest record for a mention wins. + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean) + const seen = new Map() + for (const line of lines) { + try { + const rec = JSON.parse(line) as PlaceholderRecord + seen.set(rec.mentionId, rec) + } catch { + /* skip */ + } + } + for (const rec of seen.values()) { + if (!rec.finalised) out.push(rec) + } + return out + } + + /** Mark a placeholder finalised (replaced via comment edit). */ + finalisePlaceholder(mentionId: string, placeholderId: string): void { + const path = join(this.stateDir, PLACEHOLDERS_FILE) + if (!existsSync(path)) return + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean) + const out: string[] = [] + for (const line of lines) { + try { + const rec = JSON.parse(line) as PlaceholderRecord + if (rec.mentionId === mentionId && rec.placeholderId === placeholderId) { + rec.finalised = true + out.push(JSON.stringify(rec)) + } else { + out.push(line) + } + } catch { + out.push(line) + } + } + writeFileSync(path, out.join('\n') + '\n', {mode: 0o600}) + } + + /** Was this mention already given a placeholder? */ + hasPlaceholderFor(mentionId: string): boolean { + const path = join(this.stateDir, PLACEHOLDERS_FILE) + if (!existsSync(path)) return false + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean) + for (const line of lines) { + try { + const rec = JSON.parse(line) as PlaceholderRecord + if (rec.mentionId === mentionId) return true + } catch { + /* skip */ + } + } + return false + } + + // ─── helpers ─────────────────────────────────────────────────────────────── + + private readJson(file: string, fallback: T): T { + const path = join(this.stateDir, file) + if (!existsSync(path)) return fallback + try { + return JSON.parse(readFileSync(path, 'utf-8')) as T + } catch { + return fallback + } + } + + private writeJsonAtomic(file: string, value: unknown): void { + const path = join(this.stateDir, file) + const tmp = path + '.tmp' + writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', {mode: 0o600}) + renameSync(tmp, path) + } +} + +export function mentionKey(m: Mention): string { + return m.kind === 'comment' ? `c:${m.commentId}` : `d:${m.docId}#${m.blockId ?? ''}` +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/system-context.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/system-context.ts new file mode 100644 index 000000000..64b8c2f76 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/system-context.ts @@ -0,0 +1,80 @@ +/** + * Assembles the "system context" blob used by `/ask` operator queries. + * + * Pulls together: + * - README excerpt (~3 KB) with architecture / commands / known issues + * - Last 5 audit run summaries (one line each, from index.jsonl) + * - Current governance rules JSON (from the cached GovernanceCache) + * + * Total target: ≤8 KB so DeepSeek's token budget stays comfortable. + */ + +import {existsSync, readFileSync} from 'node:fs' +import {join} from 'node:path' +import type {GovernanceCache} from './governance.js' + +const README_PATHS = [ + '/home/km/km-agent/README.md', + '/home/km/.nanobot/workspace/skill/agent/README.md', +] +const README_BUDGET = 3500 // chars + +export async function buildSystemContext(opts: { + governance: GovernanceCache + logsDir: string +}): Promise { + const sections: string[] = [] + const readme = loadReadme() + if (readme) sections.push(`### README excerpt\n${readme}`) + const runs = loadRecentRuns(opts.logsDir, 8) + if (runs) sections.push(`### Recent audit runs (last 8)\n${runs}`) + try { + const g = await opts.governance.getGovernance() + sections.push( + `### Current governance rules\n\`\`\`json\n${JSON.stringify(g.rules, null, 2)}\n\`\`\``, + ) + } catch { + /* ignore */ + } + return sections.join('\n\n') +} + +function loadReadme(): string { + for (const p of README_PATHS) { + if (existsSync(p)) { + const body = readFileSync(p, 'utf-8') + // Trim to budget — drop the layout reference at the bottom first. + return body.length > README_BUDGET ? body.slice(0, README_BUDGET) + '\n…[truncated]' : body + } + } + return '' +} + +function loadRecentRuns(logsDir: string, n: number): string { + const idx = join(logsDir, 'index.jsonl') + if (!existsSync(idx)) return '' + const lines = readFileSync(idx, 'utf-8').trim().split('\n').slice(-n) + const out: string[] = [] + for (const line of lines) { + try { + const r = JSON.parse(line) as { + id?: string + trigger?: string + start?: string + end?: string + status?: string + wall_ms?: number + counters?: Record + } + const counters = r.counters + ? Object.entries(r.counters) + .map(([k, v]) => `${k}=${v}`) + .join(' ') + : '' + out.push(`- ${r.start} ${r.trigger} status=${r.status} ${r.wall_ms}ms ${counters}`) + } catch { + /* skip */ + } + } + return out.join('\n') +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/telegram-bot.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/telegram-bot.ts new file mode 100644 index 000000000..a83cbdebd --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/telegram-bot.ts @@ -0,0 +1,343 @@ +#!/usr/bin/env node +/** + * Telegram operator-channel bot. READ-MOSTLY surface for the human + * operator to peek at agent state and trigger non-destructive actions. + * + * /status — service statuses + last-run summary. + * /last-runs [N] — last N audit runs (default 5). + * /show-rules — current governance rules JSON. + * /poll-now — kicks `systemctl --user start km-poll.service`. + * + * Security: only chat IDs listed in OPS_TELEGRAM_ID (comma-separated) + * are answered. Everyone else is silently ignored. Mutations to Seed + * documents or capabilities are NOT exposed here — for those, edit the + * governance docs from your desktop. + */ + +import {execFileSync, spawnSync} from 'node:child_process' +import {existsSync, readFileSync, readdirSync} from 'node:fs' +import {join} from 'node:path' +import {GovernanceCache} from './governance.js' +import {SeedCli} from './seedcli.js' +import {AuditRun} from './audit.js' +import {buildRedactor} from './redact.js' +import {loadConfig} from './config.js' +import {draftReply, draftSystemReply, gatherSiteContext} from './reply-engine.js' +import {ChatHistory} from './chat-history.js' +import {buildSystemContext} from './system-context.js' + +type TelegramUpdate = { + update_id: number + message?: { + message_id: number + from?: {id: number; username?: string} + chat: {id: number} + text?: string + } +} + +const POLL_TIMEOUT_SEC = 25 + +async function main(): Promise { + const token = process.env.TELEGRAM_TOKEN + if (!token) throw new Error('TELEGRAM_TOKEN not set') + const allowedIds = new Set( + (process.env.OPS_TELEGRAM_ID ?? '') + .split(/[,;]\s*/) + .filter(Boolean) + .map((s) => Number(s)) + .filter((n) => Number.isFinite(n)), + ) + if (allowedIds.size === 0) throw new Error('OPS_TELEGRAM_ID empty — refusing to expose bot to the world') + + const config = loadConfig() + const redactor = buildRedactor() + const cli = new SeedCli(config, redactor) + const governance = new GovernanceCache(config, cli) + const history = new ChatHistory(config.stateDir) + + // eslint-disable-next-line no-console + console.log(`telegram-bot listening for chats in {${[...allowedIds].join(',')}}`) + + let offset = 0 + for (;;) { + let updates: TelegramUpdate[] = [] + try { + updates = await fetchUpdates(token, offset, POLL_TIMEOUT_SEC) + } catch (err) { + // eslint-disable-next-line no-console + console.error('getUpdates failed:', err instanceof Error ? err.message : err) + await sleep(5000) + continue + } + for (const u of updates) { + offset = u.update_id + 1 + const msg = u.message + if (!msg?.from || !msg.text) continue + // Accept either the sender's user-id (DM with the bot) or the + // chat-id (group/channel where the bot is allowed to read). Both + // forms can appear in OPS_TELEGRAM_ID; the operator picks + // whichever scope they want. + if (!allowedIds.has(msg.from.id) && !allowedIds.has(msg.chat.id)) continue + try { + const text = msg.text.trim() + if (text.startsWith('/ask')) { + await handleSystemQuestion(token, msg.chat.id, text.slice(4).trim(), config, governance, history, cli) + } else if (text.startsWith('/')) { + const reply = await handleCommand(text, config, governance) + await sendMessage(token, msg.chat.id, reply) + } else { + await handleCommunityQuestion(token, msg.chat.id, text, config, cli, history) + } + } catch (err) { + const txt = err instanceof Error ? err.message : String(err) + await sendMessage(token, msg.chat.id, `❌ ${txt}`) + } + } + } +} + +async function handleCommunityQuestion( + token: string, + chatId: number, + text: string, + config: ReturnType, + cli: SeedCli, + history: ChatHistory, +): Promise { + await sendChatAction(token, chatId, 'typing') + const audit = new AuditRun({ + logsDir: config.logsDir, + trigger: 'telegram-question', + redactor: buildRedactor(), + seedSite: config.seedSite, + }) + try { + if (config.useMastraAgent) { + const {runMastraOperator} = await import('./agent/mastra-agent.js') + const turns = history.read(chatId).map((t) => ({role: t.role as 'user' | 'assistant', content: t.content})) + const result = await runMastraOperator({ + question: text, + systemContextBlob: '(community mode — pull tools as needed)', + history: turns as any, + cli, + audit, + }) + const reply = result.finalAnswer ?? 'I tried to draft a reply but hit a snag. Try rephrasing.' + await sendMessage(token, chatId, reply) + history.append(chatId, [ + {role: 'user', content: text}, + {role: 'assistant', content: reply}, + ]) + audit.trace({ts: new Date().toISOString(), level: 'info', event: 'telegram_reply_sent', data: {chatId, mode: 'mastra-community'}}) + return + } + const siteAccount = config.seedSite.replace(/^hm:\/\//, '').split('/')[0]! + const ctx = await gatherSiteContext(cli, text, siteAccount, audit) + const turns = history.read(chatId) + const answer = await draftReply(text, ctx, audit, turns) + const reply = answer ?? "I tried to draft a reply but hit a snag. Try rephrasing." + await sendMessage(token, chatId, reply) + history.append(chatId, [ + {role: 'user', content: text}, + {role: 'assistant', content: reply}, + ]) + audit.trace({ts: new Date().toISOString(), level: 'info', event: 'telegram_reply_sent', data: {chatId, mode: 'community'}}) + } finally { + audit.close({status: 'ok', logsDir: config.logsDir}) + } +} + +async function handleSystemQuestion( + token: string, + chatId: number, + question: string, + config: ReturnType, + governance: GovernanceCache, + history: ChatHistory, + cli?: SeedCli, +): Promise { + if (!question) { + await sendMessage(token, chatId, 'Usage: /ask ') + return + } + await sendChatAction(token, chatId, 'typing') + const audit = new AuditRun({ + logsDir: config.logsDir, + trigger: 'telegram-ask', + redactor: buildRedactor(), + seedSite: config.seedSite, + }) + try { + const ctx = await buildSystemContext({governance, logsDir: config.logsDir}) + if (config.useMastraAgent && cli) { + const {runMastraOperator} = await import('./agent/mastra-agent.js') + const turns = history.read(chatId).map((t) => ({role: t.role as 'user' | 'assistant', content: t.content})) + const result = await runMastraOperator({ + question, + systemContextBlob: ctx, + history: turns as any, + cli, + audit, + }) + const reply = result.finalAnswer ?? 'Could not draft a reply (DeepSeek error). Check logs.' + await sendMessage(token, chatId, reply) + history.append(chatId, [ + {role: 'user', content: `/ask ${question}`}, + {role: 'assistant', content: reply}, + ]) + audit.trace({ts: new Date().toISOString(), level: 'info', event: 'telegram_reply_sent', data: {chatId, mode: 'mastra-ask'}}) + return + } + const turns = history.read(chatId) + const answer = await draftSystemReply(question, ctx, audit, turns) + const reply = answer ?? 'Could not draft a reply (DeepSeek error). Check logs.' + await sendMessage(token, chatId, reply) + history.append(chatId, [ + {role: 'user', content: `/ask ${question}`}, + {role: 'assistant', content: reply}, + ]) + audit.trace({ts: new Date().toISOString(), level: 'info', event: 'telegram_reply_sent', data: {chatId, mode: 'ask'}}) + } finally { + audit.close({status: 'ok', logsDir: config.logsDir}) + } +} + +async function handleCommand( + text: string, + config: ReturnType, + governance: GovernanceCache, +): Promise { + const [cmd, ...rest] = text.split(/\s+/) + switch (cmd) { + case '/start': + case '/help': + return [ + 'Knowledge Manager — operator commands', + '', + '/status — service health + last-run summary', + '/last-runs [N] — recent audit runs (default 5)', + '/show-rules — current governance rules', + '/poll-now — trigger immediate poll', + '/ask — operator-mode Q&A about the bot itself (README + recent runs as context)', + '', + 'Or send a plain message: community-mode Q&A grounded in the site corpus.', + 'Conversation history is preserved per chat for follow-ups (last 10 turns).', + ].join('\n') + case '/status': + return formatStatus(config.logsDir) + case '/last-runs': { + const n = Math.max(1, Math.min(20, parseInt(rest[0] ?? '5', 10) || 5)) + return formatRecentRuns(config.logsDir, n) + } + case '/show-rules': { + const g = await governance.getGovernance(true) + return '```\n' + JSON.stringify({rules: g.rules, allowlist: g.allowlist}, null, 2) + '\n```' + } + case '/poll-now': { + const r = spawnSync('systemctl', ['--user', 'start', 'km-poll.service'], {encoding: 'utf-8'}) + return r.status === 0 ? '✓ poll triggered' : `❌ ${r.stderr || 'unknown error'}` + } + default: + return 'Unknown command. Try /help.' + } +} + +function formatStatus(logsDir: string): string { + const services = ['nanobot-gateway', 'km-poll.timer', 'km-boletin.timer', 'km-gap.timer', 'km-health.timer', 'km-telegram'] + const lines: string[] = ['*Service status*'] + for (const s of services) { + let r + try { + r = execFileSync('systemctl', ['--user', 'is-active', s], {encoding: 'utf-8'}).trim() + } catch (e) { + r = (e as {stdout?: string}).stdout?.toString().trim() ?? 'unknown' + } + lines.push(`${r === 'active' ? '🟢' : '🔴'} ${s}: ${r}`) + } + const idx = join(logsDir, 'index.jsonl') + if (existsSync(idx)) { + const tail = readFileSync(idx, 'utf-8').trim().split('\n').slice(-3) + lines.push('', '*Last 3 runs*') + for (const line of tail) { + try { + const r = JSON.parse(line) as {trigger?: string; start?: string; status?: string; wall_ms?: number} + lines.push(`• ${r.start} ${r.trigger} → ${r.status} (${r.wall_ms}ms)`) + } catch { + /* skip */ + } + } + } + return lines.join('\n') +} + +function formatRecentRuns(logsDir: string, n: number): string { + const runsDir = join(logsDir, 'runs') + if (!existsSync(runsDir)) return 'no runs yet' + const dirs = readdirSync(runsDir) + .filter((d) => existsSync(join(runsDir, d, 'meta.json'))) + .sort() + .slice(-n) + const lines: string[] = [] + for (const d of dirs.reverse()) { + try { + const meta = JSON.parse(readFileSync(join(runsDir, d, 'meta.json'), 'utf-8')) as { + trigger?: string + startedAt?: string + wallMs?: number + status?: string + counters?: Record + } + const counters = meta.counters + ? Object.entries(meta.counters) + .map(([k, v]) => `${k}=${v}`) + .join(' ') + : '' + lines.push(`• ${meta.startedAt} ${meta.trigger} ${meta.status} ${meta.wallMs}ms ${counters}`) + } catch { + /* skip */ + } + } + return lines.length === 0 ? 'no runs yet' : lines.join('\n') +} + +async function fetchUpdates(token: string, offset: number, timeout: number): Promise { + const url = `https://api.telegram.org/bot${token}/getUpdates?timeout=${timeout}&offset=${offset}` + const r = await fetch(url) + if (!r.ok) throw new Error(`getUpdates ${r.status}`) + const json = (await r.json()) as {ok: boolean; result?: TelegramUpdate[]; description?: string} + if (!json.ok) throw new Error(json.description ?? 'getUpdates !ok') + return json.result ?? [] +} + +async function sendMessage(token: string, chatId: number, text: string): Promise { + await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({chat_id: chatId, text, parse_mode: 'Markdown'}), + }) +} + +async function sendChatAction(token: string, chatId: number, action: 'typing'): Promise { + // Best-effort; ignore failures. + try { + await fetch(`https://api.telegram.org/bot${token}/sendChatAction`, { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({chat_id: chatId, action}), + }) + } catch { + /* ignore */ + } +} + +function sleep(ms: number): Promise { + return new Promise((res) => setTimeout(res, ms)) +} + +void AuditRun +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('telegram-bot fatal:', err) + process.exit(1) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/thread-reply.test.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/thread-reply.test.ts new file mode 100644 index 000000000..192e92ffa --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/thread-reply.test.ts @@ -0,0 +1,265 @@ +import {describe, expect, it} from 'bun:test' +import {buildThreadReplyMention, detectThreadReplyToKm, findKmMentionInComment} from './mentions.js' +import type {SeedComment} from './mentions.js' + +const KM = 'z6MkAgent' +const USER = 'z6MkUser' +const SITE = 'z6MkSite' + +function makeComment( + id: string, + author: string, + replyParent?: string, + opts: {text?: string; targetPath?: string} = {}, +): SeedComment { + return { + id, + author, + targetAccount: SITE, + targetPath: opts.targetPath, + replyParent, + content: [{block: {id: 'b1', text: opts.text ?? 'hi'}}], + } +} + +describe('detectThreadReplyToKm', () => { + it('returns hit when the direct parent is KM', async () => { + const parent = makeComment('c-parent', KM) + const cache = new Map() + const fetchComment = async (id: string) => (id === 'c-parent' ? parent : null) + const child = makeComment('c-child', USER, 'c-parent') + const result = await detectThreadReplyToKm({comment: child, kmAccountId: KM, fetchComment, cache}) + expect(result).toEqual({ancestorCommentId: 'c-parent'}) + }) + + it('returns hit when KM is a transitive ancestor', async () => { + const root = makeComment('c-root', KM) + const mid = makeComment('c-mid', USER, 'c-root') + const cache = new Map() + const fetchComment = async (id: string): Promise => { + if (id === 'c-root') return root + if (id === 'c-mid') return mid + return null + } + const child = makeComment('c-child', USER, 'c-mid') + const result = await detectThreadReplyToKm({comment: child, kmAccountId: KM, fetchComment, cache}) + expect(result).toEqual({ancestorCommentId: 'c-root'}) + }) + + it('returns null when no KM ancestor exists', async () => { + const parent = makeComment('c-parent', USER) + const cache = new Map() + const fetchComment = async (id: string) => (id === 'c-parent' ? parent : null) + const child = makeComment('c-child', USER, 'c-parent') + const result = await detectThreadReplyToKm({comment: child, kmAccountId: KM, fetchComment, cache}) + expect(result).toBeNull() + }) + + it('returns null when the comment has no replyParent', async () => { + const cache = new Map() + const fetchComment = async () => null + const orphan = makeComment('c-1', USER) + const result = await detectThreadReplyToKm({comment: orphan, kmAccountId: KM, fetchComment, cache}) + expect(result).toBeNull() + }) + + it('caps the walk at maxHops', async () => { + const cache = new Map() + let fetches = 0 + const fetchComment = async (id: string): Promise => { + fetches++ + const n = Number(id.split('-')[1]) + return makeComment(id, USER, `c-${n - 1}`) + } + const start = makeComment('c-100', USER, 'c-99') + const result = await detectThreadReplyToKm({ + comment: start, + kmAccountId: KM, + fetchComment, + cache, + maxHops: 5, + }) + expect(result).toBeNull() + expect(fetches).toBeLessThanOrEqual(5) + }) + + it('does not infinite-loop on a reply cycle', async () => { + const a = makeComment('c-a', USER, 'c-b') + const b = makeComment('c-b', USER, 'c-a') + const cache = new Map() + const fetchComment = async (id: string): Promise => { + if (id === 'c-a') return a + if (id === 'c-b') return b + return null + } + const result = await detectThreadReplyToKm({comment: a, kmAccountId: KM, fetchComment, cache}) + expect(result).toBeNull() + }) + + it('reuses the cache to avoid refetching shared ancestors', async () => { + const parent = makeComment('c-parent', KM) + let fetches = 0 + const cache = new Map() + const fetchComment = async (id: string): Promise => { + fetches++ + return id === 'c-parent' ? parent : null + } + const child1 = makeComment('c-1', USER, 'c-parent') + const child2 = makeComment('c-2', USER, 'c-parent') + await detectThreadReplyToKm({comment: child1, kmAccountId: KM, fetchComment, cache}) + await detectThreadReplyToKm({comment: child2, kmAccountId: KM, fetchComment, cache}) + expect(fetches).toBe(1) + }) + + it('stops when fetchComment returns null (parent unavailable)', async () => { + const cache = new Map() + const fetchComment = async () => null + const child = makeComment('c-child', USER, 'c-missing') + const result = await detectThreadReplyToKm({comment: child, kmAccountId: KM, fetchComment, cache}) + expect(result).toBeNull() + }) +}) + +describe('findKmMentionInComment — embed false positives', () => { + it('detects a bare account embed as a mention', () => { + const comment: SeedComment = { + id: 'c1', + author: USER, + targetAccount: SITE, + content: [ + { + block: { + id: 'b1', + text: 'hi ', + annotations: [{type: 'Embed', link: `hm://${SITE}`}], + }, + }, + ], + } + const result = findKmMentionInComment(comment, [SITE]) + expect(result).not.toBeNull() + expect(result!.blockId).toBe('b1') + }) + + it('does NOT treat a document embed link as a mention', () => { + const comment: SeedComment = { + id: 'c2', + author: USER, + targetAccount: SITE, + content: [ + { + block: { + id: 'b1', + text: 'check this doc ', + annotations: [{type: 'Embed', link: `hm://${SITE}/tech-talks/measurement`}], + }, + }, + ], + } + const result = findKmMentionInComment(comment, [SITE]) + expect(result).toBeNull() + }) + + it('detects /:profile embed as a mention', () => { + const comment: SeedComment = { + id: 'c-profile', + author: USER, + targetAccount: SITE, + content: [ + { + block: { + id: 'b1', + text: ' what do you think?', + annotations: [{type: 'Embed', link: `hm://${SITE}/:profile`}], + }, + }, + ], + } + const result = findKmMentionInComment(comment, [SITE]) + expect(result).not.toBeNull() + expect(result!.blockId).toBe('b1') + }) + + it('detects /:profile?v=... embed as a mention', () => { + const comment: SeedComment = { + id: 'c-profile-ver', + author: USER, + targetAccount: SITE, + content: [ + { + block: { + id: 'b1', + text: '', + annotations: [{type: 'Embed', link: `hm://${SITE}/:profile?v=bafy123&l`}], + }, + }, + ], + } + const result = findKmMentionInComment(comment, [SITE]) + expect(result).not.toBeNull() + }) + + it('does NOT treat a nested document path as a mention', () => { + const comment: SeedComment = { + id: 'c3', + author: USER, + targetAccount: SITE, + content: [ + { + block: { + id: 'b1', + text: '', + annotations: [{type: 'Embed', link: `hm://${SITE}/projects/data-flow`}], + }, + }, + ], + } + const result = findKmMentionInComment(comment, [SITE]) + expect(result).toBeNull() + }) +}) + +describe('buildThreadReplyMention', () => { + it('concatenates all block texts and tags trigger source', () => { + const c: SeedComment = { + id: 'c1', + author: USER, + targetAccount: SITE, + targetPath: '/page', + content: [ + {block: {id: 'b1', text: 'line one'}}, + {block: {id: 'b2', text: 'line two'}}, + ], + } + const m = buildThreadReplyMention(c, '2026-05-11T00:00:00Z') + expect(m.kind).toBe('comment') + expect(m.commentId).toBe('c1') + expect(m.author).toBe(USER) + expect(m.docId).toBe(`hm://${SITE}/page`) + expect(m.blockId).toBeUndefined() + expect(m.triggerSource).toBe('thread-reply') + expect(m.text).toBe('line one\nline two') + }) + + it('strips U+FFFC object-replacement characters', () => { + const c: SeedComment = { + id: 'c2', + author: USER, + targetAccount: SITE, + content: [{block: {id: 'b1', text: 'hi  there'}}], + } + const m = buildThreadReplyMention(c, 'ts') + expect(m.text).toBe('hi there') + }) + + it('renders docId without a path when targetPath is absent', () => { + const c: SeedComment = { + id: 'c3', + author: USER, + targetAccount: SITE, + content: [{block: {id: 'b1', text: 'question'}}], + } + const m = buildThreadReplyMention(c, 'ts') + expect(m.docId).toBe(`hm://${SITE}`) + }) +}) diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/tools.ts b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/tools.ts new file mode 100644 index 000000000..e89285759 --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/src/tools.ts @@ -0,0 +1,641 @@ +/** + * MCP tool registry. Each tool wraps either: + * - a read operation on `seed-cli`, + * - a write operation that goes through governance + rate-limit checks, + * - or a state-mutation helper used by the LLM's polling loop. + * + * Tool inputs are validated with zod; outputs are returned as JSON + * payloads conforming to the MCP `CallToolResult` shape. + */ + +import {z} from 'zod' +import type {Server} from '@modelcontextprotocol/sdk/server/index.js' +import {CallToolRequestSchema, ListToolsRequestSchema} from '@modelcontextprotocol/sdk/types.js' +import type {AgentConfig} from './config.js' +import type {AuditRun} from './audit.js' +import type {SeedCli} from './seedcli.js' +import type {GovernanceCache} from './governance.js' +import type {State} from './state.js' +import type {Mention} from './mentions.js' +import { + classifyEvent, + mentionsAccount, + buildReplyTarget, + commentEventCandidate, + findKmMentionInComment, + buildCommentMention, +} from './mentions.js' +import type {SeedComment} from './mentions.js' +import {bump, checkCap, isWriteAllowed} from './limits.js' + +type ToolDef = { + name: string + description: string + inputSchema: object + call: (input: unknown) => Promise +} + +export type ToolDeps = { + config: AgentConfig + cli: SeedCli + governance: GovernanceCache + state: State + audit: AuditRun + /** Resolved at boot from `seed-cli key list`. */ + kmAccountId: string +} + +export function buildTools(deps: ToolDeps): ToolDef[] { + const {config, cli, governance, state, audit, kmAccountId} = deps + + const tools: ToolDef[] = [] + + tools.push({ + name: 'seed_get_governance', + description: 'Fetch and return the agent\'s governance documents (charter, rules, runbook, allowlist) parsed from the target Seed site. Cached for 60s.', + inputSchema: z.object({force: z.boolean().optional()}).describe('force=true bypasses cache.'), + async call(input) { + const args = z.object({force: z.boolean().optional()}).parse(input ?? {}) + const g = await governance.getGovernance(args.force ?? false) + audit.trace({ts: nowIso(), level: 'info', event: 'governance_loaded', data: {fetchedAt: g.fetchedAt}}) + return { + rules: g.rules, + allowlist: g.allowlist, + charter: g.charter, + runbook: g.runbook, + fetchedAt: g.fetchedAt, + } + }, + }) + + tools.push({ + name: 'seed_search', + description: 'Search documents in the target site. Wraps `seed-cli search`.', + inputSchema: z.object({query: z.string(), limit: z.number().int().min(1).max(50).optional()}), + async call(input) { + const args = z.object({query: z.string(), limit: z.number().int().optional()}).parse(input) + const argv = ['search', args.query, '-a', stripHm(config.seedSite)] + if (args.limit) argv.push('--limit', String(args.limit)) + const r = await cli.runRead(argv) + return r.parsedJson ?? {raw: r.stdout} + }, + }) + + tools.push({ + name: 'seed_query_space', + description: 'List documents under the target site (or a path prefix). Wraps `seed-cli query`.', + inputSchema: z.object({ + path: z.string().optional(), + mode: z.enum(['Children', 'AllDescendants']).optional(), + limit: z.number().int().min(1).max(200).optional(), + sort: z.enum(['Path', 'Title', 'CreateTime', 'UpdateTime', 'DisplayTime']).optional(), + reverse: z.boolean().optional(), + }), + async call(input) { + const a = z + .object({ + path: z.string().optional(), + mode: z.string().optional(), + limit: z.number().int().optional(), + sort: z.string().optional(), + reverse: z.boolean().optional(), + }) + .parse(input ?? {}) + const argv = ['query', config.seedSite] + if (a.path) argv.push('--path', a.path) + if (a.mode) argv.push('--mode', a.mode) + if (a.limit) argv.push('--limit', String(a.limit)) + if (a.sort) argv.push('--sort', a.sort) + if (a.reverse) argv.push('--reverse') + const r = await cli.runRead(argv) + return r.parsedJson ?? {raw: r.stdout} + }, + }) + + tools.push({ + name: 'seed_get_document', + description: 'Fetch a document as Markdown with frontmatter. Wraps `seed-cli document get`.', + inputSchema: z.object({id: z.string()}), + async call(input) { + const a = z.object({id: z.string()}).parse(input) + const r = await cli.runRead(['document', 'get', a.id]) + return {markdown: r.stdout, exitCode: r.exitCode} + }, + }) + + tools.push({ + name: 'seed_list_comments', + description: 'List comments on a document. Wraps `seed-cli comment list`.', + inputSchema: z.object({targetId: z.string()}), + async call(input) { + const a = z.object({targetId: z.string()}).parse(input) + const r = await cli.runRead(['comment', 'list', a.targetId]) + return r.parsedJson ?? {raw: r.stdout} + }, + }) + + tools.push({ + name: 'seed_get_comment_thread', + description: + 'Walk the replyParent chain from a comment up to the thread root. Returns the thread oldest→newest with each comment’s author and body. Caps at 30 comments.', + inputSchema: z.object({commentId: z.string(), max: z.number().int().min(1).max(100).optional()}), + async call(input) { + const a = z.object({commentId: z.string(), max: z.number().int().optional()}).parse(input) + const max = a.max ?? 30 + const collected: Array> = [] + let current = a.commentId + for (let i = 0; i < max; i++) { + const r = await cli.runRead(['comment', 'get', current]) + if (r.exitCode !== 0 || !r.parsedJson) break + const c = r.parsedJson as {replyParent?: string} & Record + collected.unshift(c) + if (!c.replyParent) break + current = c.replyParent + } + return {thread: collected} + }, + }) + + tools.push({ + name: 'seed_site_sync_status', + description: + 'Report local-daemon subscription state and writer-capability availability for a site. Wraps `seed-cli site sync-status`.', + inputSchema: z.object({siteId: z.string(), writer: z.string().optional()}), + async call(input) { + const a = z.object({siteId: z.string(), writer: z.string().optional()}).parse(input) + const argv = ['site', 'sync-status', a.siteId] + if (a.writer) argv.push('--writer', a.writer) + const r = await cli.runRead(argv) + return r.parsedJson ?? {raw: r.stdout, exitCode: r.exitCode} + }, + }) + + tools.push({ + name: 'seed_get_activity', + description: 'Fetch activity events for the target site. Optional cursor token for pagination. Wraps `seed-cli activity`.', + inputSchema: z.object({ + token: z.string().optional(), + limit: z.number().int().min(1).max(500).optional(), + resource: z.string().optional(), + }), + async call(input) { + const a = z.object({token: z.string().optional(), limit: z.number().int().optional(), resource: z.string().optional()}).parse(input ?? {}) + const argv = ['activity'] + if (a.token) argv.push('--token', a.token) + if (a.limit) argv.push('--limit', String(a.limit)) + argv.push('--resource', a.resource ?? config.seedSite) + const r = await cli.runRead(argv) + return r.parsedJson ?? {raw: r.stdout} + }, + }) + + tools.push({ + name: 'seed_get_citations', + description: 'List documents/comments that cite a given Hypermedia ID. Wraps `seed-cli citations`.', + inputSchema: z.object({id: z.string()}), + async call(input) { + const a = z.object({id: z.string()}).parse(input) + const r = await cli.runRead(['citations', a.id]) + return r.parsedJson ?? {raw: r.stdout} + }, + }) + + tools.push({ + name: 'seed_list_capabilities', + description: 'List capabilities granted on a Seed account/site. Used to derive the WRITER set. Wraps `seed-cli account capabilities`.', + inputSchema: z.object({accountId: z.string().optional()}), + async call(input) { + const a = z.object({accountId: z.string().optional()}).parse(input ?? {}) + const target = a.accountId ?? config.seedSite + const r = await cli.runRead(['account', 'capabilities', target]) + return r.parsedJson ?? {raw: r.stdout} + }, + }) + + // ─── writes ────────────────────────────────────────────────────────────── + + tools.push({ + name: 'seed_create_document', + description: 'Create a document at a path on the target site. Enforces governance.allowWritePaths, denyWritePaths, draft_only kill-switch, max_documents_per_run cap.', + inputSchema: z.object({ + path: z.string(), + title: z.string(), + bodyMarkdown: z.string(), + }), + async call(input) { + const a = z.object({path: z.string(), title: z.string(), bodyMarkdown: z.string()}).parse(input) + const g = await governance.getGovernance() + if (g.rules.draftOnly) { + audit.trace({ts: nowIso(), level: 'warn', event: 'write_blocked_by_rules', data: {path: a.path, reason: 'draft_only'}}) + return {written: false, reason: 'draft_only'} + } + const allow = isWriteAllowed(a.path, g.rules) + if (!allow.allowed) { + audit.trace({ts: nowIso(), level: 'warn', event: 'write_blocked_by_rules', data: {path: a.path, reason: allow.reason}}) + return {written: false, reason: allow.reason} + } + const cap = checkCap(state.getRateState(), 'documents', g.rules) + if (!cap.allowed) { + audit.trace({ts: nowIso(), level: 'warn', event: 'write_blocked_by_rules', data: {path: a.path, reason: cap.reason}}) + return {written: false, reason: cap.reason} + } + // Write body to temp file because seed-cli expects --file. + const tmpFile = await writeTempMarkdown(a.bodyMarkdown) + const argv = [ + 'document', + 'create', + '--account', + stripHm(config.seedSite), + '--path', + a.path, + '--name', + a.title, + '--file', + tmpFile, + ] + const r = await cli.runWrite(argv) + state.setRateState(bump(state.getRateState(), 'documents')) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'document_created', + data: {path: a.path, exit: r.exitCode, link: parseLinkFromStdout(r.stdout)}, + }) + return {written: r.exitCode === 0, exitCode: r.exitCode, stdout: r.stdout} + }, + }) + + tools.push({ + name: 'seed_update_document', + description: 'Update an existing document. Same governance + rate checks as create.', + inputSchema: z.object({id: z.string(), bodyMarkdown: z.string(), title: z.string().optional(), summary: z.string().optional()}), + async call(input) { + const a = z.object({id: z.string(), bodyMarkdown: z.string(), title: z.string().optional(), summary: z.string().optional()}).parse(input) + const g = await governance.getGovernance() + const path = pathFromHmId(a.id) + if (g.rules.draftOnly) return {written: false, reason: 'draft_only'} + const allow = isWriteAllowed(path, g.rules) + if (!allow.allowed) return {written: false, reason: allow.reason} + const cap = checkCap(state.getRateState(), 'documents', g.rules) + if (!cap.allowed) return {written: false, reason: cap.reason} + const tmpFile = await writeTempMarkdown(a.bodyMarkdown) + const argv = ['document', 'update', a.id, '-f', tmpFile] + if (a.title) argv.push('--title', a.title) + if (a.summary) argv.push('--summary', a.summary) + const r = await cli.runWrite(argv) + state.setRateState(bump(state.getRateState(), 'documents')) + return {written: r.exitCode === 0, exitCode: r.exitCode, stdout: r.stdout} + }, + }) + + tools.push({ + name: 'seed_create_comment', + description: 'Create a comment on a document or block. Enforces draft_only and per-run/per-day caps.', + inputSchema: z.object({ + targetId: z.string().describe('hm://… optionally with #blockId'), + body: z.string(), + }), + async call(input) { + const a = z.object({targetId: z.string(), body: z.string()}).parse(input) + const g = await governance.getGovernance() + const cap = checkCap(state.getRateState(), 'comments', g.rules) + if (!cap.allowed) { + audit.trace({ts: nowIso(), level: 'warn', event: 'write_blocked_by_rules', data: {target: a.targetId, reason: cap.reason}}) + return {posted: false, reason: cap.reason} + } + // draft_only does NOT block comments (comments are how the agent + // communicates regardless). It only blocks document writes. + const argv = ['comment', 'create', a.targetId, '--body', a.body] + const r = await cli.runWrite(argv) + state.setRateState(bump(state.getRateState(), 'comments')) + audit.trace({ts: nowIso(), level: 'info', event: 'comment_posted', data: {target: a.targetId, exit: r.exitCode}}) + return {posted: r.exitCode === 0, exitCode: r.exitCode, stdout: r.stdout} + }, + }) + + tools.push({ + name: 'seed_reply_comment', + description: 'Reply to an existing comment (creates a thread). Same caps as seed_create_comment.', + inputSchema: z.object({targetId: z.string(), parentCommentId: z.string(), body: z.string()}), + async call(input) { + const a = z.object({targetId: z.string(), parentCommentId: z.string(), body: z.string()}).parse(input) + const g = await governance.getGovernance() + const cap = checkCap(state.getRateState(), 'comments', g.rules) + if (!cap.allowed) return {posted: false, reason: cap.reason} + const argv = ['comment', 'create', a.targetId, '--reply', a.parentCommentId, '--body', a.body] + const r = await cli.runWrite(argv) + state.setRateState(bump(state.getRateState(), 'comments')) + return {posted: r.exitCode === 0, exitCode: r.exitCode, stdout: r.stdout} + }, + }) + + // ─── state helpers ─────────────────────────────────────────────────────── + + tools.push({ + name: 'cursor_get', + description: 'Read the activity-cursor token used by the polling loop.', + inputSchema: z.object({}).strict(), + async call() { + return {token: state.getCursor()} + }, + }) + + tools.push({ + name: 'cursor_set', + description: 'Write the activity-cursor token for the polling loop.', + inputSchema: z.object({token: z.string()}), + async call(input) { + const a = z.object({token: z.string()}).parse(input) + state.setCursor(a.token) + return {ok: true} + }, + }) + + tools.push({ + name: 'inbox_pop', + description: 'Pop the oldest pending mention from the inbox queue.', + inputSchema: z.object({}).strict(), + async call() { + return {mention: state.popFromInbox()} + }, + }) + + tools.push({ + name: 'inbox_size', + description: 'Number of mentions waiting in the inbox queue.', + inputSchema: z.object({}).strict(), + async call() { + return {size: state.inboxSize()} + }, + }) + + tools.push({ + name: 'inbox_mark_done', + description: 'Record a mention as processed (idempotency). Status: replied | not-allowed | error.', + inputSchema: z.object({ + mention: z.unknown(), + runId: z.string(), + status: z.enum(['replied', 'not-allowed', 'error']), + }), + async call(input) { + const a = z.object({mention: z.unknown(), runId: z.string(), status: z.enum(['replied', 'not-allowed', 'error'])}).parse(input) + state.markProcessed(a.mention as Mention, a.runId, a.status) + return {ok: true} + }, + }) + + tools.push({ + name: 'inbox_enqueue_from_event', + description: 'Classify a raw activity event for a mention of the agent and enqueue it if matched. Returns the parsed mention or null.', + inputSchema: z.object({event: z.unknown()}), + async call(input) { + const a = z.object({event: z.unknown()}).parse(input) + const mention = classifyEvent(a.event as Parameters[0], kmAccountId) + if (mention) state.enqueue(mention) + return {mention} + }, + }) + + tools.push({ + name: 'mention_target_for_reply', + description: 'Given a mention, returns the {targetId, replyTo?} payload to pass to seed_create_comment / seed_reply_comment.', + inputSchema: z.object({mention: z.unknown()}), + async call(input) { + const a = z.object({mention: z.unknown()}).parse(input) + return buildReplyTarget(a.mention as Mention) + }, + }) + + tools.push({ + name: 'check_mention_text', + description: 'Returns true if the given text contains a mention of the agent\'s accountId.', + inputSchema: z.object({text: z.string()}), + async call(input) { + const a = z.object({text: z.string()}).parse(input) + return {mentions: mentionsAccount(a.text, kmAccountId)} + }, + }) + + tools.push({ + name: 'poll_collect', + description: + "Deterministic poll loop step. Loads governance + writer capabilities, fetches activity since last cursor, filters for mentions of the agent by allowed invokers, enqueues them, advances the cursor, and returns the queue. After this returns, the LLM should iterate over `pending` and call seed_reply_comment / seed_create_comment + inbox_mark_done for each.", + inputSchema: z.object({}).strict(), + async call() { + const g = await governance.getGovernance() + // Resolve allowed invoker set. + let allowedInvokers: Set + if (g.rules.mentions.invokerSource === 'allowlist-doc') { + allowedInvokers = new Set(g.allowlist.invokers) + } else { + const capsResult = await cli.runRead(['account', 'capabilities', config.seedSite]) + const writers = new Set() + try { + const parsed = capsResult.parsedJson as {capabilities?: Array<{delegate?: string; role?: string}>} + for (const c of parsed.capabilities ?? []) { + if (c.role === 'WRITER' && c.delegate) writers.add(c.delegate) + } + } catch { + /* ignore */ + } + // The site account itself counts as a writer. + writers.add(stripHm(config.seedSite)) + allowedInvokers = writers + } + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'poll_collect_writers', + data: {count: allowedInvokers.size}, + }) + // Activity feed is reverse-chronological. We fetch the first page + // and stop walking as soon as we hit the lastEventId we processed + // last poll. Comment bodies are not in the feed, so for each + // candidate comment event we fetch the full comment via + // `comment get` and inspect its annotations for an Embed link to + // the agent's accountId. + const lastSeenId = state.getCursor() + // First-time runs (no cursor) shouldn't backfill the entire history + // through `comment get` calls — cap the work we do per poll. + const MAX_COMMENT_FETCHES = 25 + // Note: the activity feed's `--resource` filter is exact-match on the + // doc path, so filtering by the site root would exclude comments + // posted on subdocuments (`/discussions/...`, `/agents/...`). We pull + // the unfiltered feed and post-filter by `comment.targetAccount`. + const r = await cli.runRead(['activity', '--limit', '50']) + const siteAccount = stripHm(config.seedSite) + let events: Array<{id?: string; type?: string; time?: string; author?: string | {id?: {uid?: string}}}> = [] + try { + const parsed = r.parsedJson as {events?: typeof events} + events = parsed.events ?? [] + } catch { + /* ignore */ + } + let newestEventId: string | undefined + const blocked = new Set(g.rules.moderation.blockedAuthors) + let enqueued = 0 + let skippedNotAllowed = 0 + let scannedComments = 0 + let exhaustedBudget = false + for (const ev of events) { + if (!newestEventId && ev.id) newestEventId = ev.id + if (lastSeenId && ev.id === lastSeenId) break + if (scannedComments >= MAX_COMMENT_FETCHES) { + exhaustedBudget = true + break + } + const candidate = commentEventCandidate(ev) + if (!candidate) continue + scannedComments++ + // Fetch the full comment to inspect annotations. + const cr = await cli.runRead(['comment', 'get', candidate.commentId]) + if (cr.exitCode !== 0 || !cr.parsedJson) continue + const comment = cr.parsedJson as SeedComment + // Skip comments not targeting our site. + if (comment.targetAccount !== siteAccount) continue + // Don't reply to ourselves. + if (comment.author === kmAccountId) continue + const evidence = findKmMentionInComment(comment, [kmAccountId, siteAccount]) + if (!evidence) continue + const mention = buildCommentMention(comment, evidence, candidate.ts) + if (blocked.has(mention.author)) continue + if (!allowedInvokers.has(mention.author)) { + state.markProcessed(mention, audit.meta.runId, 'not-allowed') + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'mention_skipped_not_allowed', + data: {author: mention.author, kind: mention.kind, docId: mention.docId}, + }) + skippedNotAllowed++ + continue + } + state.enqueue(mention) + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'mention_enqueued', + data: { + author: mention.author, + kind: mention.kind, + docId: mention.docId, + commentId: mention.commentId, + blockId: mention.blockId, + }, + }) + enqueued++ + } + if (newestEventId) state.setCursor(newestEventId) + // Drain inbox up to the per-run comment cap into the response. + const cap = g.rules.caps.maxCommentsPerRun + const pending: unknown[] = [] + for (let i = 0; i < cap; i++) { + const m = state.popFromInbox() + if (!m) break + const target = buildReplyTarget(m) + pending.push({mention: m, target}) + } + audit.trace({ + ts: nowIso(), + level: 'info', + event: 'poll_collect_done', + data: { + events: events.length, + scannedComments, + enqueued, + skippedNotAllowed, + pendingForReply: pending.length, + cursorAdvanced: Boolean(newestEventId), + exhaustedBudget, + }, + }) + return { + eventsScanned: events.length, + scannedComments, + enqueued, + skippedNotAllowed, + pending, + cursorAdvanced: Boolean(newestEventId), + newestEventId: newestEventId ?? null, + exhaustedBudget, + runId: audit.meta.runId, + } + }, + }) + + return tools +} + +export function registerToolHandlers(server: Server, tools: ToolDef[]): void { + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: tools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: jsonSchema(t.inputSchema), + })), + })) + + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const tool = tools.find((t) => t.name === req.params.name) + if (!tool) { + return {content: [{type: 'text' as const, text: `unknown tool: ${req.params.name}`}], isError: true} + } + const start = Date.now() + const tsStart = nowIso() + try { + const result = await tool.call(req.params.arguments) + return {content: [{type: 'text' as const, text: JSON.stringify(result)}], _meta: {tsStart, latencyMs: Date.now() - start}} + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { + content: [{type: 'text' as const, text: JSON.stringify({error: msg})}], + isError: true, + _meta: {tsStart, latencyMs: Date.now() - start}, + } + } + }) +} + +// ─── helpers ────────────────────────────────────────────────────────────── + +function nowIso(): string { + return new Date().toISOString() +} + +function stripHm(id: string): string { + return id.replace(/^hm:\/\//, '').split('/')[0]! +} + +function pathFromHmId(id: string): string { + const stripped = id.replace(/^hm:\/\//, '') + const idx = stripped.indexOf('/') + return idx === -1 ? '/' : stripped.slice(idx) +} + +function parseLinkFromStdout(s: string): string | undefined { + const m = s.match(/https?:\/\/\S+/) + return m?.[0] +} + +async function writeTempMarkdown(body: string): Promise { + const {writeFileSync} = await import('node:fs') + const {tmpdir} = await import('node:os') + const {join} = await import('node:path') + const path = join(tmpdir(), `km-doc-${Date.now()}-${Math.random().toString(36).slice(2)}.md`) + writeFileSync(path, body, {mode: 0o600}) + return path +} + +// MCP tools want a JSON Schema, not a zod schema. We accept either: if the +// caller passes a zod schema we call its `_def` extractor; otherwise pass +// through. (Production MCP servers use zod-to-json-schema; we keep it +// minimal here.) +function jsonSchema(s: object): object { + if (typeof (s as {_def?: unknown})._def !== 'undefined') { + // This is a zod schema. Provide a minimal, permissive schema; the LLM + // will see the description text. For production, swap in + // zod-to-json-schema. + return {type: 'object', additionalProperties: true} + } + return s +} diff --git a/seed-knowledge-manager/agent/mcp/seed-cli-mcp/tsconfig.json b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/tsconfig.json new file mode 100644 index 000000000..38210fb1e --- /dev/null +++ b/seed-knowledge-manager/agent/mcp/seed-cli-mcp/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": true, + "resolveJsonModule": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": false + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "src/__tests__/**"] +} diff --git a/seed-knowledge-manager/agent/scripts/.gitkeep b/seed-knowledge-manager/agent/scripts/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/seed-knowledge-manager/agent/scripts/bootstrap-subscription.sh b/seed-knowledge-manager/agent/scripts/bootstrap-subscription.sh new file mode 100755 index 000000000..86f841d33 --- /dev/null +++ b/seed-knowledge-manager/agent/scripts/bootstrap-subscription.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Subscribe the local daemon to the production site so it mirrors all docs, +# capability blobs, and comments. Idempotent — relies on a flag file to avoid +# re-subscribing on every boot. Run as user `km`. +# +# Usage: +# bash bootstrap-subscription.sh [] +# +# The first arg is the site to subscribe (recursive). The optional second arg +# is the agent's account id; if provided, the script also waits until a +# WRITER capability for that account has converged locally. + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 []" >&2 + exit 2 +fi + +SITE="$1" +WRITER_AID="${2:-}" +LOCAL_DAEMON="${SEED_LOCAL_DAEMON_URL:-http://127.0.0.1:3000}" +STATE_DIR="${KM_STATE_DIR:-$HOME/km-state}" +FLAG="$STATE_DIR/subscribed.flag" + +mkdir -p "$STATE_DIR" + +if [[ -f "$FLAG" ]] && grep -qF "$SITE" "$FLAG"; then + echo "[bootstrap] subscription for $SITE already recorded, skipping subscribe RPC" +else + # Always async — the daemon's first DiscoverObject can run for ~10 minutes, + # but the Remix /api proxy times the underlying socket out far sooner. We + # poll sync-status below to know when it's actually ready. + echo "[bootstrap] subscribing local daemon to $SITE (recursive, async)" + /home/km/.local/bin/seed-cli -s "$LOCAL_DAEMON" site subscribe "$SITE" --recursive + echo "$SITE" >> "$FLAG" +fi + +# Wait until a writer cap is locally cached for the agent. Up to 15 minutes, +# nudging the daemon every 30s to keep the smart-sync hot. +if [[ -n "$WRITER_AID" ]]; then + echo "[bootstrap] waiting for WRITER capability of $WRITER_AID on $SITE to converge" + for i in $(seq 1 180); do + STATUS=$(/home/km/.local/bin/seed-cli -s "$LOCAL_DAEMON" site sync-status "$SITE" --writer "$WRITER_AID" -q || true) + if [[ "$STATUS" == "ready" ]]; then + echo "[bootstrap] ready_for_writes=true after $i polls" + exit 0 + fi + # Nudge the daemon every 30s. + if (( i % 6 == 0 )); then + /home/km/.local/bin/seed-cli -s "$LOCAL_DAEMON" site reconcile -q || true + fi + sleep 5 + done + echo "[bootstrap] WARN: writer cap did not converge in 15min — agent will still start, will keep retrying via km-poll preflight" >&2 + exit 0 +fi diff --git a/seed-knowledge-manager/agent/scripts/install-phase1.sh b/seed-knowledge-manager/agent/scripts/install-phase1.sh new file mode 100755 index 000000000..9afa9c98e --- /dev/null +++ b/seed-knowledge-manager/agent/scripts/install-phase1.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Phase 1 — Server bootstrap on oc.hyper.media. +# Idempotent. Safe to re-run. +# +# Usage: +# bash seed-knowledge-manager/agent/scripts/install-phase1.sh ubuntu@oc.hyper.media +# +# What it does: +# - Installs OS dependencies (libsecret tools, bubblewrap, node, pipx, jq, rsync). +# - Creates system user `km` with linger enabled and added to the `docker` group. +# - Drops the seed-daemon docker compose file under /home/km/seed-daemon/. +# - Installs systemd --user unit for the seed-daemon container. +# - Starts the daemon and waits for /debug/version on 127.0.0.1:55001. + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +TARGET="$1" +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +AGENT_DIR="$REPO_ROOT/seed-knowledge-manager/agent" + +echo "==> Phase 1 bootstrap on $TARGET" + +# ---- Step 1: apt packages ------------------------------------------------- +ssh "$TARGET" 'sudo bash -s' <<'REMOTE_APT' +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq \ + python3.12 python3.12-venv pipx \ + libsecret-1-0 libsecret-tools dbus-user-session bubblewrap \ + nodejs npm jq curl rsync logrotate +REMOTE_APT + +# ---- Step 2: km user, linger, docker group ------------------------------- +ssh "$TARGET" 'sudo bash -s' <<'REMOTE_USER' +set -euo pipefail +if ! getent passwd km >/dev/null; then + useradd --create-home --shell /bin/bash km + echo "[+] created user km" +fi +usermod -aG docker km || true +loginctl enable-linger km +install -d -m 700 -o km -g km /home/km/seed-daemon /home/km/seed-daemon/data +install -d -m 700 -o km -g km /home/km/.config/systemd/user +install -d -m 700 -o km -g km /home/km/.local/bin +REMOTE_USER + +# ---- Step 3: drop compose file + systemd unit ---------------------------- +TMP=$(ssh "$TARGET" 'mktemp -d') +trap 'ssh "$TARGET" "rm -rf $TMP"' EXIT +rsync -avz "$AGENT_DIR/seed-daemon/compose.yaml" "$TARGET:$TMP/compose.yaml" +rsync -avz "$AGENT_DIR/systemd/seed-daemon.service" "$TARGET:$TMP/seed-daemon.service" + +ssh "$TARGET" "sudo install -m 644 -o km -g km '$TMP/compose.yaml' /home/km/seed-daemon/compose.yaml" +ssh "$TARGET" "sudo install -m 644 -o km -g km '$TMP/seed-daemon.service' /home/km/.config/systemd/user/seed-daemon.service" + +# ---- Step 4: enable + start daemon --------------------------------------- +ssh "$TARGET" 'sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) bash -s' <<'REMOTE_DAEMON' +set -euo pipefail +export XDG_RUNTIME_DIR="/run/user/$(id -u)" +systemctl --user daemon-reload +systemctl --user enable seed-daemon.service +systemctl --user start seed-daemon.service +REMOTE_DAEMON + +# NOTE on seed-cli: +# The published `@seed-hypermedia/cli@0.1.4` on npm currently has an +# unresolved `workspace:*` dep (`@seed-hypermedia/client`) which makes +# `npx -y @seed-hypermedia/cli` fail with `Unsupported URL Type "workspace:"`. +# We deliberately do NOT install seed-cli in Phase 1. Phase 2 builds it from +# this repo's `frontend/apps/cli/` workspace (pnpm + bun) on the server. + +# ---- Step 5: wait for HTTP API ------------------------------------------- +echo "==> waiting for daemon HTTP API on 127.0.0.1:55001" +for i in $(seq 1 30); do + if ssh "$TARGET" 'curl -fsS http://127.0.0.1:55001/debug/version >/dev/null 2>&1'; then + echo "==> daemon healthy after $i tries" + ssh "$TARGET" 'curl -s http://127.0.0.1:55001/debug/version' + echo + exit 0 + fi + sleep 2 +done + +echo "!! daemon did not become healthy in 60s" >&2 +ssh "$TARGET" 'sudo -u km XDG_RUNTIME_DIR=/run/user/$(id -u km) journalctl --user -u seed-daemon.service --no-pager -n 100' || true +exit 1 diff --git a/seed-knowledge-manager/agent/scripts/km-log b/seed-knowledge-manager/agent/scripts/km-log new file mode 100755 index 000000000..5f12ec9ba --- /dev/null +++ b/seed-knowledge-manager/agent/scripts/km-log @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Browse the Knowledge Manager agent's per-run audit logs. +# +# Layout (per Phase 5): +# /home/km/km-logs/ +# ├── runs/____/ +# │ ├── meta.json +# │ ├── trace.jsonl +# │ ├── llm.jsonl +# │ ├── tools.jsonl +# │ ├── seed-cli.jsonl +# │ ├── stdout.log +# │ └── stderr.log +# ├── current -> runs/ +# └── index.jsonl +# +# Subcommands: +# km-log tail # follow the newest run's trace + journal +# km-log show # pretty-print a specific run +# km-log grep # ripgrep across all run dirs +# km-log mention # find the run that processed a comment/doc id +# km-log latest [N] # last N runs (summary) +# +# Reads use jq when present. + +set -euo pipefail + +LOGS_DIR="${KM_LOGS_DIR:-/home/km/km-logs}" +RUNS_DIR="$LOGS_DIR/runs" + +die() { echo "$*" >&2; exit 1; } + +usage() { + sed -n '2,30p' "$0" | sed 's/^# //;s/^#//' + exit 0 +} + +resolve_run() { + local ref="$1" + if [[ -d "$RUNS_DIR/$ref" ]]; then + echo "$RUNS_DIR/$ref" + return + fi + # match by ULID suffix + for d in "$RUNS_DIR"/*"$ref"*; do + [[ -d "$d" ]] && { echo "$d"; return; } + done + die "no run matching '$ref' under $RUNS_DIR" +} + +cmd_tail() { + local run + if [[ -L "$LOGS_DIR/current" ]]; then + run="$LOGS_DIR/current" + else + run=$(ls -dt "$RUNS_DIR"/*/ 2>/dev/null | head -1) + fi + [[ -d "$run" ]] || die "no runs yet under $RUNS_DIR" + echo "tailing $run/trace.jsonl" + tail -F "$run/trace.jsonl" +} + +cmd_show() { + local ref="${1:-}"; [[ -n "$ref" ]] || die "usage: km-log show " + local d; d=$(resolve_run "$ref") + echo "=== $d ===" + if command -v jq >/dev/null; then + echo "--- meta" + jq . "$d/meta.json" 2>/dev/null || cat "$d/meta.json" + echo "--- trace" + [[ -f "$d/trace.jsonl" ]] && jq -c . "$d/trace.jsonl" + echo "--- llm" + [[ -f "$d/llm.jsonl" ]] && jq -c '{ts_start, latency_ms, model, completion: (.completion // null | tostring | .[0:200]), reasoning: (.reasoning // null | tostring | .[0:300]), usage}' "$d/llm.jsonl" + echo "--- tools" + [[ -f "$d/tools.jsonl" ]] && jq -c '{ts_start, latency_ms, tool, error}' "$d/tools.jsonl" + echo "--- seed-cli" + [[ -f "$d/seed-cli.jsonl" ]] && jq -c '{ts_start, latency_ms, exit_code, argv: (.argv|.[2:6])}' "$d/seed-cli.jsonl" + else + cat "$d/meta.json" + echo + [[ -f "$d/trace.jsonl" ]] && cat "$d/trace.jsonl" + fi +} + +cmd_grep() { + local pattern="${1:-}"; [[ -n "$pattern" ]] || die "usage: km-log grep " + local cmd + if command -v rg >/dev/null; then cmd="rg --no-heading -n"; else cmd="grep -rn"; fi + $cmd "$pattern" "$RUNS_DIR" || true +} + +cmd_mention() { + local id="${1:-}"; [[ -n "$id" ]] || die "usage: km-log mention " + cmd_grep "$id" +} + +cmd_latest() { + local n="${1:-10}" + if [[ -f "$LOGS_DIR/index.jsonl" && -x $(command -v jq) ]]; then + tail -n "$n" "$LOGS_DIR/index.jsonl" | jq -c '{id, trigger, start, end, status, wall_ms, counters}' + else + ls -dt "$RUNS_DIR"/*/ 2>/dev/null | head -n "$n" + fi +} + +case "${1:-}" in + tail) shift; cmd_tail "$@" ;; + show) shift; cmd_show "$@" ;; + grep) shift; cmd_grep "$@" ;; + mention) shift; cmd_mention "$@" ;; + latest) shift; cmd_latest "$@" ;; + ""|-h|--help) usage ;; + *) echo "unknown subcommand: $1" >&2; usage ;; +esac diff --git a/seed-knowledge-manager/agent/scripts/secret-tool-shim b/seed-knowledge-manager/agent/scripts/secret-tool-shim new file mode 100755 index 000000000..b7396432a --- /dev/null +++ b/seed-knowledge-manager/agent/scripts/secret-tool-shim @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Drop-in shim for `secret-tool` used by seed-cli on a headless server. +# +# Stores secrets in a JSON file at ${SECRET_TOOL_FILE:-$HOME/.config/seed-keyring/secrets.json} +# (mode 600). Implements only the subset that seed-cli needs: +# secret-tool lookup [attr value]... +# secret-tool store --label