moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,215 @@
# PR3.5 Recon — SSE/Chat "step" event path & provenance attach point
**Question:** Can a `source`/provenance field attach to streamed agent "step" events
(to power the `⬡ source · when` ProvenanceLine pill that PR3 wired date-only)?
**Verdict: NEEDS-WIRING.** A streamed `step` event today carries *only* `{ content: string }`
no frame id, no `source`, no provenance of any kind. The frame `source` *does* exist in the
substrate (`MemoryFrame.source: FrameSource`), but it is flattened to formatted text strings
before it ever reaches the SSE layer. Attaching `source` to a step is a real (but small &
well-scoped) plumbing job, not a trivial projection. The cleanest path is **not** to enrich the
generic `step` event — it's to enrich the `tool_result` event for the `auto_recall` (memory) tool,
which is the only step type that has a real provenance signal.
---
## 1. Server — where steps are produced & streamed
**Route:** `packages/server/src/local/routes/chat.ts``POST /api/chat`, SSE via `reply.hijack()`.
**SSE writer (the only emit helper):** `chat.ts:475`
```ts
const sendEvent = (event: string, data: unknown) => {
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
```
### The `step` payload shape (server)
Every `step` event the server emits has the **same minimal shape**: `{ content: string }`. There is
no other field. Representative emit sites:
- `chat.ts:761``sendEvent('step', { content: 'Recalling relevant memories...' })`
- `chat.ts:780``sendEvent('step', { content: 'Recalled N relevant memories.' })`
- `chat.ts:1170``sendEvent('step', { content: stepText })` where `stepText = describeToolUse(name, input)`
(this is the **per-tool-call** step, fired from the agent-loop `onToolUse` callback — the main step source)
- plus budget/approval/compression/GEPA steps at `:740 :754 :771 :904 :921 :929 :1023 :1027 :1108 :1352 :1382 :1389 :1439 :1458` — all `{ content }` only.
### The agent-loop emission (where tool steps originate)
**`packages/agent/src/agent-loop.ts`** does **not** emit `step` events itself. It exposes typed
callbacks (`AgentLoopConfig`, `agent-loop.ts:37-39`):
```ts
onToken?: (token: string) => void;
onToolUse?: (name: string, input: Record<string, unknown>) => void;
onToolResult?: (name: string, input: Record<string, unknown>, result: string) => void;
```
The route wires these to SSE in the runner config (`chat.ts:1167-1185` / `:1186-1220`):
```ts
onToolUse: (name, input) => {
const stepText = describeToolUse(name, input);
sendEvent('step', { content: stepText }); // ← the step
sendEvent('tool', { name, input }); // ← raw tool event (has name+input)
...
},
onToolResult: (name, input, result) => {
...
sendEvent('tool_result', { name, result, duration, isError }); // ← name+result+duration
...
},
```
**Key fact:** the loop's callbacks expose `name`, `input`, `result` — but **no frame, no `source`,
no provenance**. The agent loop has no concept of which memory frame a step touched.
### The related `tool_result` shape (server)
`chat.ts:1200``{ name, result, duration, isError }`. For the memory tool specifically:
`chat.ts:781``sendEvent('tool_result', { name: 'auto_recall', result: resultText, duration, isError })`
where `resultText` is **already-rendered text** (`chat.ts:777-779`):
```ts
const snippets = (recall.recalled ?? []).slice(0, 3);
const snippetText = snippets.map(s => ` - ${s}`).join('\n');
const resultText = `${recall.count} memories recalled:\n${snippetText}`;
```
### Why the provenance is lost: `recallMemory()` returns text, not frames
`packages/agent/src/orchestrator.ts:453-457`:
```ts
async recallMemory(...): Promise<{ text: string; count: number; recalled?: string[] }>
```
`recalled` is a `string[]` of **formatted snippets** — the frame objects (which *do* carry
`source`) are collapsed to display strings inside `recallMemory` before returning. The real
provenance lives one layer deeper: `packages/hive-mind-core/src/mind/frames.ts:35`
`MemoryFrame.source: FrameSource` (e.g. `user_stated`, harvest adapters: chatgpt/claude/etc.),
plus `sourceUrl`/`sourceId` extras. So **the data exists in the DB, it is just not projected up
through `recallMemory` → SSE.**
---
## 2. Frontend — SSE consumer → activity stream blocks
### SSE parse + event-type mapping
**`apps/web/src/lib/adapter.ts:692-738`** (`streamChat` async generator) parses the raw SSE
text and maps event names to `StreamEvent.type`:
- `adapter.ts:729``else if (type === 'step') type = 'step';`
- yields `{ type, data } as StreamEvent` (`adapter.ts:732`) — `data` is the parsed `{ content }`.
### `StreamEvent` type
**`apps/web/src/lib/types.ts:616-619`**:
```ts
export interface StreamEvent {
type: 'token' | 'step' | 'tool_start' | 'tool_end' | 'done' | 'error' | 'approval_request' | 'approval_required' | 'model_switch' | 'notification';
data: unknown;
}
```
### Step → block reduction
**`apps/web/src/hooks/useChat.ts:152-165`** turns a `step` event into a `StepContentBlock`:
```ts
case 'step': {
const description = typeof data === 'string' ? data : (data?.content as string ?? '');
if (description) {
// mark prior running steps done
blocks.push({ type: 'step', blockId: nextBlockId('step'), description, status: 'running' });
}
break;
}
```
### `StepContentBlock` type (client) — **the field set to extend**
**`apps/web/src/lib/types.ts:474-479`**:
```ts
export interface StepContentBlock {
type: 'step';
blockId: string;
description: string;
status: 'running' | 'done';
}
```
No `source`/`provenance` field. This is the type that would gain an optional
`provenance?: { source: string; when?: string }`.
### Where provenance is (not yet) shown — PR3's date-only affordance
**`apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx:28-43`** groups a consecutive run
of step blocks into one `ActivityStream` card. PR3 already left the hook here and an honest comment
(`BlockRenderer.tsx:24-26`):
```ts
// Provenance pills are intentionally omitted: the SSE
// `step` payload carries no structured source field today (recon chat.md §4) —
// we render the affordance, never fabricated provenance.
```
And the map drops provenance (`BlockRenderer.tsx:30-33`):
```ts
const activitySteps: ActivityStep[] = steps.map(s => ({
tone: s.status === 'running' ? 'honey' : 'intel',
text: s.description, // ← no provenance projected
}));
```
The rendering target already exists and is wired:
- **`ActivityStream`** (`components/os/warm/ActivityStream.tsx:8-13`) — `ActivityStep.provenance?: { source; when?; onClick? }`, rendered at `:67-71` **only when present**.
- **`ProvenanceLine`** (`components/os/warm/ProvenanceLine.tsx:19-32`) — the `⬡ source · when` pill.
So the **client consumer is provenance-ready**; it is waiting for the server to deliver a `source`.
---
## 3. Honest verdict — trivial vs needs-wiring
**NEEDS-WIRING (small, scoped). NOT trivially projectable.**
- A generic `step` (e.g. "Drafting the document", "Budget limit reached", a `bash` tool call) has
**no provenance at all** and never will — those steps are not memory reads. Stamping them with a
`source` would be fabrication (exactly what PR3's comment refuses). So a blanket "add source to
every step" is wrong on the merits.
- The **one** step with a genuine provenance signal is the **memory recall** (`auto_recall`). Its
source is real (`MemoryFrame.source`) but is destroyed by `recallMemory()` returning `string[]`
text instead of frame metadata.
### The precise new wiring needed
1. **Substrate → orchestrator (the load-bearing change):** widen `recallMemory()`'s return in
`packages/agent/src/orchestrator.ts:453-457` to carry per-snippet provenance, e.g. add
`recalledFrames?: Array<{ text: string; source: FrameSource; sourceUrl?: string; when?: string }>`
alongside the existing `recalled: string[]`. The frame objects with `.source` are already in hand
inside `recallMemory` — this is a "stop flattening it" change, not a new query. (This is the
*same* 1-field server projection the S2 handoff named: *"the `frame.source` 1-field server
projection (unlocks the ⬡ provenance pill on Chat+Workspace)"*.)
2. **Server SSE:** in `chat.ts` around `:780-781`, emit the source on the memory step/tool_result —
either add `source`/`provenance` to the existing `tool_result` (`{ name:'auto_recall', ... }`) or
to the `step` payload for that one event: `sendEvent('step', { content, provenance: { source, when } })`.
(`{ content }``{ content, provenance? }` is additive and backward-compatible.)
3. **Client types:** add optional `provenance?: { source: string; when?: string }` to
`StepContentBlock` (`types.ts:474`) and to the `StreamEvent` `data` handling.
4. **Client reducer:** in `useChat.ts:152` carry `data.provenance` onto the pushed step block.
5. **Client render:** in `BlockRenderer.tsx:30-33` project `s.provenance` into `ActivityStep` (the
`ActivityStream`/`ProvenanceLine` rendering path already exists and gates on presence).
**Effort estimate:** ~5 small edits across 4 files (orchestrator return-shape widen is the only
non-trivial one; everything else is a 1-field pass-through). No new DB columns, no new query — the
`source` already exists at `frames.ts:35`. Lowest-risk slice = wire it on `auto_recall` only
(memory steps), leave all other steps provenance-less by design.
### Scope call for PR3.5
**In-scope and the right size for PR3.5** *iff* paired with the `recallMemory` return-shape widen.
If PR3.5 wants to stay frontend-only, then provenance on steps is **out-of-scope** (the data is not
on the wire) — and the honest move is to keep PR3's "render the affordance, never fabricated
provenance" stance until the server projection lands.
---
## Appendix — file:line index
| Concern | File:line |
|---|---|
| SSE writer | `packages/server/src/local/routes/chat.ts:475` |
| step payload (memory) | `chat.ts:761`, `:780` |
| step payload (per tool call) | `chat.ts:1170` (via `describeToolUse`) |
| tool_result payload | `chat.ts:1200`; memory variant `chat.ts:781` |
| recall snippet build (text flatten) | `chat.ts:777-779` |
| agent-loop callbacks (no source) | `packages/agent/src/agent-loop.ts:37-39`, wired `chat.ts:1167-1220` |
| recallMemory return shape | `packages/agent/src/orchestrator.ts:453-457` |
| frame `source` field (real provenance) | `packages/hive-mind-core/src/mind/frames.ts:35` |
| SSE parse + map | `apps/web/src/lib/adapter.ts:717-732` |
| StreamEvent type | `apps/web/src/lib/types.ts:616-619` |
| step → block reducer | `apps/web/src/hooks/useChat.ts:152-165` |
| StepContentBlock type | `apps/web/src/lib/types.ts:474-479` |
| activity grouping + omitted-provenance comment | `apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx:24-43` |
| ActivityStream (provenance-ready) | `apps/web/src/components/os/warm/ActivityStream.tsx:8-13, 67-71` |
| ProvenanceLine pill | `apps/web/src/components/os/warm/ProvenanceLine.tsx:19-32` |