Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
54f82e0
feat: add knowledge manager skill based on LAFH/GC-Red methodology wi…
horacioh May 5, 2026
dbc40ae
feat(agent): add Phase 1 server bootstrap with seed-daemon Docker com…
horacioh May 5, 2026
d1a1acc
feat(agent): add secret-tool shim and seed-web service to compose stack
horacioh May 5, 2026
26a3df7
feat(agent): seed-cli MCP wrapper foundation (Phase 3)
horacioh May 6, 2026
0a77cb5
feat(agent): nanobot gateway config + systemd unit (Phase 4)
horacioh May 6, 2026
a33f78f
feat(agent): mention polling driver with typing-indicator (Phase 5 + …
horacioh May 6, 2026
6f9cbd2
feat(agent): scheduled LAFH cadences (Phase 6)
horacioh May 6, 2026
0752808
feat(agent): Telegram operator bot with /ask + multi-turn (Phase 7)
horacioh May 6, 2026
f444e1c
docs(agent): operator runbook + verification matrix (Phase 8)
horacioh May 6, 2026
f576d8e
fix(agent): drop cursor-based activity walker, enrich reply context w…
horacioh May 6, 2026
0435392
feat: add site subscription commands, agent subscription-hot-tier, an…
horacioh May 7, 2026
ef86db8
feat(agent): add Seed markdown primer, parent index auto-creation, an…
horacioh May 11, 2026
e5f955f
fix(agent): cap tool budget at 10, gate invoker enforcement, resolve …
horacioh May 11, 2026
e8c7fce
feat(agent): trigger KM replies on thread replies without explicit me…
horacioh May 12, 2026
c97c2b4
fmt
horacioh May 12, 2026
fa54757
fix(agent): work around seed-cli --reply CID parse failure in thread …
horacioh May 12, 2026
be8dc38
feat(agent): add observability center telemetry
horacioh May 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions .ai/prompts/km-thread-reply-trigger.md
Original file line number Diff line number Diff line change
@@ -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<commentId, boolean>` 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/<RUN_ID>/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.
289 changes: 289 additions & 0 deletions .ai/seed-cli-reply-chain-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
# Fix: `seed-cli comment create --reply` fails after comment edit

## Bug summary

`seed-cli comment create <target> --reply <commentId>` 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<SignedComment> {
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
```
Loading
Loading