Files
Maven/internal/ipc/api.go
claude d3c63e6493 ipc: a seed_event method, step-up gated, refused by the store (V-518)
The pattern detector needs four events for one action+object spread by at
least two hours before it proposes a routine. The only writer in the tree is
a fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
same missing step. This is the wire half of the seam that unblocks them.

The request takes a fact — key, value, timestamp — not an event, so
pattern.Extract runs for real on the daemon side and a key the extractor
ignores seeds nothing. The response says which of those happened, because a
caller that assumed a seed always yields an event would read four silent
successes as a broken detector.

AuthStepUp, the same rung as mutating the tool allowlist, and not because
backdating is privileged in the usual sense: every other write records when
something happened and this one asserts it. StoreAPI refuses outright — the
method needs the daemon's detect-and-propose step, and a direct store caller
would write a fact and quietly skip it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-05 01:10:06 +04:00

881 lines
35 KiB
Go

package ipc
import (
"context"
"errors"
"time"
"github.com/kami/maven/internal/audio"
)
// DTOs — wire-level data. Decoupled from internal/store so the protocol is
// self-describing and a module never needs to import store internals (the
// boundary is the point). The store adapter maps store.* ⇔ these 1:1.
// Fact — one observation. Ts is valid-time (true-as-of), as in store.
type Fact struct {
ID int64 `json:"id"`
Ts time.Time `json:"ts"`
Kind string `json:"kind"` // "self" | "env" | "config"
Key string `json:"key"`
Value string `json:"value"` // raw json if structured
Source string `json:"source"` // tap:*|infer:*|poll:*|ambient|promote|feedback
Confidence float64 `json:"confidence"`
VoidsID *int64 `json:"voids_id,omitempty"`
}
// EcosystemTrace — one hop of a cross-service ecosystem call, read by the
// monitoring surfaces. Traces live in their own store table, not in facts:
// they are written at machine rate and would otherwise crowd every bounded
// reader of facts.
type EcosystemTrace struct {
ID int64 `json:"id"`
Ts time.Time `json:"ts"`
Service string `json:"service"`
Operation string `json:"operation"`
Status string `json:"status"`
DurationMs int64 `json:"duration_ms"`
CorrelationID string `json:"correlation_id"`
CausationID string `json:"causation_id"`
HTTPStatus int `json:"http_status"`
Fields map[string]any `json:"fields,omitempty"`
}
// Bucket — presence hysteresis state: "present" | "away".
type Bucket string
const (
Present Bucket = "present"
Away Bucket = "away"
)
// Nudge — one proactive send + its outcome, for the monitoring read path.
type Nudge struct {
ID int64 `json:"id"`
Ts time.Time `json:"ts"`
Rule string `json:"rule"`
Channel string `json:"channel"`
Message string `json:"message"`
Outcome string `json:"outcome"` // pending|acted|snoozed|ignored
OutcomeTs *int64 `json:"outcome_ts,omitempty"`
}
// DeliveryAttempt — one row of the delivery outbox. Times are formatted by the
// reader; Completed is nil while the attempt is still pending.
type DeliveryAttempt struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Rule string `json:"rule,omitempty"`
ReminderID int64 `json:"reminder_id,omitempty"`
Channel string `json:"channel"`
Status string `json:"status"`
Created time.Time `json:"created"`
Completed *time.Time `json:"completed,omitempty"`
}
// Note — a recall/preference item; ranked by embedding cosine on query.
// Score is set by QueryNotes (0 on the write path).
type Note struct {
ID int64 `json:"id"`
Ts time.Time `json:"ts"`
Text string `json:"text"`
Source string `json:"source"`
Score float64 `json:"score"`
}
// Reminder — user-stated future intent; fires once or recurring (if cron set).
type Reminder struct {
ID int64 `json:"id"`
CreatedTs time.Time `json:"created_ts"`
FireTs time.Time `json:"fire_ts"`
NextFireTs time.Time `json:"next_fire_ts"`
Payload string `json:"payload"`
Status string `json:"status"` // pending|fired|cancelled
Cron string `json:"cron"`
}
// Presence — the read the phraser / delivery modules need to decide channel
// routing and tone. presence = reachability, NOT wakefulness (spec). Loop
// reads probes + computes score itself; modules get the resolved snapshot.
type Presence struct {
Bucket Bucket `json:"bucket"`
Score float64 `json:"score"`
Updated time.Time `json:"updated"`
}
// WriteFactReq — the only state mutation a capture/tool module performs.
// Confidence is 1.0 for taps, (0,1) for inferences; the store enforces range.
// Source is provenance — the server-side source-scope seam (auth layer) will
// refuse a module writing under a source it doesn't own ("compromised poller
// can't forge a trigger"). Today the floor permits any local caller.
type WriteFactReq struct {
Ts time.Time `json:"ts"`
Kind string `json:"kind"`
Key string `json:"key"`
Value string `json:"value"`
Source string `json:"source"`
Confidence float64 `json:"confidence"`
VoidsID *int64 `json:"voids_id,omitempty"`
// Subject — free-text "who/what this fact is about" (e.g. "the espresso
// machine", "Kate"). Empty (the default, so old callers are unaffected)
// means the fact isn't about a resolvable entity. When set, the fact
// enrichment worker (cmd/mavend/factenrichment.go) later resolves it
// against Nexus into an entity_id — see Vikunja #279.
Subject string `json:"subject,omitempty"`
}
// Task — one captured piece of work (Vikunja #130). Status is
// "candidate" (Maven derived it and it is unconfirmed), "open" (his work),
// "done" or "dropped". Source is provenance in the facts vocabulary:
// "tap:voice", "tap:web", "email:<account>". Evidence is the trail a derived
// task came from, empty for anything he stated himself.
type Task struct {
ID int64 `json:"id"`
CreatedTs time.Time `json:"created_ts"`
Text string `json:"text"`
Source string `json:"source"`
Evidence string `json:"evidence,omitempty"`
ExternalID string `json:"external_id,omitempty"`
Status string `json:"status"`
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
Resolved *time.Time `json:"resolved,omitempty"`
ResolvedBy string `json:"resolved_by,omitempty"`
}
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
// through this one shape: the voice path, the web form, and (Vikunja #246) the
// email reader, which has not been built yet.
//
// An extractor that reads mail sets Source "email:<account>", Status
// "candidate", and Evidence to whatever makes the task reviewable (the subject
// line). It cannot set Status "open" — work Maven inferred from something she
// read is a suggestion until the owner confirms it on the /tasks page, and the
// store refuses an open capture from a derived source rather than trusting the
// caller to have read this paragraph.
//
// ExternalID is what makes re-reading free for such a source, and it is
// REQUIRED of one. Text dedupe only covers live rows, because a voice capture
// of the same errand next week is a new task. A mailbox has no such signal: it
// hands back the same immutable message forever, so a task he already finished
// would come back as a fresh candidate on the next poll. ExternalID is unique
// over every row whatever its status: message id plus the extracted span.
type CaptureTaskReq struct {
Text string `json:"text"`
Source string `json:"source"`
Evidence string `json:"evidence,omitempty"`
ExternalID string `json:"external_id,omitempty"`
Status string `json:"status,omitempty"` // "" ⇒ open
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
Ts time.Time `json:"ts"`
}
// CaptureTaskResp — Created is false when the same live task already existed,
// in which case ID is the existing row. A caller tells the owner "уже в
// списке" rather than claiming it saved something new.
type CaptureTaskResp struct {
ID int64 `json:"id"`
Created bool `json:"created"`
// Promoted — this capture turned an existing candidate into open work. He
// stated out loud something Maven had only proposed, which is a
// confirmation, and the caller says so rather than "уже в списке".
Promoted bool `json:"promoted,omitempty"`
}
// SeedEventReq — write one fact at a caller-supplied timestamp and run the
// pattern path over it, so a recurring routine can be produced on demand
// instead of over real days (Vikunja #518).
//
// This is the ONLY backdating write path in the tree, and it exists for one
// reason: the detector needs four events spread over hours before it proposes
// anything, so V-43, V-46, V-247 and V-254 could not be verified against a
// running daemon at all. A store fixture would have exercised the detector
// without the wiring those tasks doubt.
//
// Two things hold it shut. It is AuthStepUp in the authority table, the same
// rung as mutating the tool allowlist. And mavend refuses it outright unless
// started with -allow-seed, so a box nobody is testing carries no live
// backdating path even for a caller who cleared the gate.
//
// Key and Value are a fact, not an event: extraction runs for real, so a key
// the extractor ignores seeds nothing and says so. That is deliberate — a
// seam that accepted action and object directly would let QA prove a detector
// against events no utterance could ever produce.
type SeedEventReq struct {
Key string `json:"key"`
Value string `json:"value"`
Ts time.Time `json:"ts"`
}
// SeedEventResp — what the seed produced. Extracted is false when the fact was
// written but yielded no event, which is the extractor declining rather than a
// failure. Proposed is true only when this seed completed a pattern; the first
// three seeds of a run return false with no routine.
type SeedEventResp struct {
FactID int64 `json:"fact_id"`
EventID int64 `json:"event_id,omitempty"`
Extracted bool `json:"extracted"`
Action string `json:"action,omitempty"`
Object string `json:"object,omitempty"`
Proposed bool `json:"proposed"`
RoutineID int64 `json:"routine_id,omitempty"`
// IntervalDays — the median the detector settled on, echoed so QA can
// check it against the spacing it asked for.
IntervalDays float64 `json:"interval_days,omitempty"`
}
// IngestMailReq — one message a mail reader has fetched, handed to core for
// extraction (Vikunja #246).
//
// The mail reader (cmd/mavmaild) holds the IMAP credential and core never sees
// it, the same split mavpoll uses for the zenmoney token. What crosses this
// boundary is only the message text, because extraction runs on the resident
// model and llama-server lives inside core's process.
//
// Body is already plaintext and truncated by internal/email; core does not
// re-parse MIME and never stores the body. Junk means the reader's header
// filter already classified the message as bulk — core is told rather than
// asked, so a junk message can be counted without a model call.
//
// This method is available only when core has an email block configured AND a
// llama-server phraser; otherwise it answers ErrUnknownMethod, which is what
// "off unless configured" looks like at the wire.
type IngestMailReq struct {
Mailbox string `json:"mailbox"`
UID uint32 `json:"uid"`
From string `json:"from,omitempty"`
Subject string `json:"subject,omitempty"`
Date string `json:"date,omitempty"`
Body string `json:"body,omitempty"`
Junk bool `json:"junk,omitempty"`
}
// IngestMailResp — what core did with the message. TaskIDs are the rows
// CaptureTask returned; Created counts the ones that were new (a re-read
// mailbox dedupes to Created=0). Skipped is set when nothing was asked of the
// model at all — junk, or an empty message.
//
// Created == 0 && !Skipped therefore means the model WAS consulted and found no
// task, which is the common answer. A reader deciding whether to mark a UID
// seen should treat that the same as a success: asking again would spend the
// resident model on the same negative answer. Skipped means the same for a
// different reason. Only an error means "not read yet".
//
// Nothing here echoes the mail back. The reader logs counts.
type IngestMailResp struct {
TaskIDs []int64 `json:"task_ids,omitempty"`
Created int `json:"created"`
Skipped bool `json:"skipped,omitempty"`
}
// DescribeImageReq — one image handed to core to look at (Vikunja #252).
//
// Data is the raw image file as received (png / jpeg / gif). Core sniffs it and
// refuses anything else; a declared content type is not part of this request
// because the sender's claim about its own bytes is not evidence. Base64 on the
// wire via the usual JSON marshal of []byte.
//
// Question is what he asked about the picture ("что тут написано?"). Empty ⇒
// core uses its configured default prompt.
//
// Source is provenance recorded on the stored blob: "telegram", "web:upload".
//
// Exactly one of Data or ID is set, and core refuses a request carrying both:
// it used to take the ID branch and drop the bytes without a word.
//
// The method exists when core has a media store. Vision being off does NOT
// remove it: the bytes are stored and the answer says she cannot read the
// picture yet, which is re-runnable by ID once a vision model is on disk, and
// it is the state this box is in today. So a surface that gets a reply with an
// id and an empty description has not failed, it has stored something. With no
// media block the method answers ErrUnknownMethod, which is what "off unless
// configured" looks like at the wire.
type DescribeImageReq struct {
Data []byte `json:"data,omitempty"`
ID string `json:"id,omitempty"`
Source string `json:"source,omitempty"`
Question string `json:"question,omitempty"`
// SaveNote — also write the description as a note (source
// "media:image:<id-prefix>") so it is recallable later. Default false: a
// glance at a screenshot is not automatically a memory.
//
// Setting it raises what the call needs: an embedded note is recall corpus,
// so the caller's source scope must cover auth.ImageNoteSource. Describing
// without saving stays an ordinary read.
SaveNote bool `json:"save_note,omitempty"`
}
// DescribeImageResp — what she saw. ID is the stored blob's content address, and
// it is set even when Description is empty because the description failed: the
// bytes are on disk and the same id can be retried. NoteID is non-zero only when
// SaveNote was set and the write succeeded.
//
// The image itself is never echoed back.
type DescribeImageResp struct {
ID string `json:"id"`
Description string `json:"description,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
NoteID int64 `json:"note_id,omitempty"`
}
// CaptureStartReq — begin recording a meeting (Vikunja #253).
//
// Label is what the meeting is called ("встреча с подрядчиком"); it goes into
// the summary note so the note is findable later. Empty is allowed.
//
// There is no "auto", no keyword and no schedule in this request, and there will
// not be: the only way audio enters the recorder is a client that was told to
// start, appending frames it was told to append. All four capture methods answer
// ErrUnknownMethod unless the operator enabled a capture block, so a surface
// cannot start a recording by asking nicely.
type CaptureStartReq struct {
Label string `json:"label,omitempty"`
}
// CaptureStartResp — the session that opened. MaxSeconds is the hard cap after
// which it stops itself; the caller tells him, so a forgotten recording is his
// own informed choice rather than a surprise.
type CaptureStartResp struct {
Label string `json:"label,omitempty"`
Started time.Time `json:"started"`
// Token names THIS session. Every later append, stop and discard has to
// carry it. Without it the recorder is addressed by "whatever is running
// now", and a client whose session already ended on the duration cap goes on
// appending its microphone into the next session someone else started.
Token string `json:"token"`
MaxSeconds int `json:"max_seconds"`
}
// CaptureAppendReq — one chunk of audio for the running session. Refused with
// "nothing is being recorded" when no session is open, which is the guard that
// makes an ambient path impossible: audio arriving at an idle core is dropped on
// the floor, not buffered "just in case".
type CaptureAppendReq struct {
// Token from CaptureStartResp. A frame for a session that already ended is
// refused rather than folded into whatever is running now.
Token string `json:"token"`
Audio audio.Audio `json:"audio"`
}
// CaptureAppendResp — how much has been collected, so a client can show a timer
// and notice the cap coming. Expired means the session hit its limit and closed;
// stop sending and call capture_stop, the audio so far is kept.
type CaptureAppendResp struct {
Seconds float64 `json:"seconds"`
Expired bool `json:"expired,omitempty"`
}
// CaptureStopReq — end the running session.
//
// Discard throws the recording away without transcribing, storing or
// summarising anything. This is what "забудь, не записывай" maps to, and it is a
// flag rather than a separate method so the client that says "stop" and the
// client that says "stop and forget" take the same path to the same session.
type CaptureStopReq struct {
// Token from CaptureStartResp. Stopping by "whatever is running" lets a
// late client end a recording it never started.
Token string `json:"token"`
Discard bool `json:"discard,omitempty"`
}
// CaptureStopResp — the finished capture. BlobID is the stored WAV, kept under
// media.retention like any other blob and pruned with it.
//
// A response with a Transcript and an empty Summary is the normal shape, not a
// failure: summarising a long meeting is a map-reduce of minutes, so stop
// answers with the words and the summary note is written afterwards. Summary is
// filled in only when it happened to be ready. A response with a BlobID and no
// transcript is the audio surviving a transcription failure — the same id can be
// run again by hand off the blob before media.retention prunes it — there is no
// capture method that takes a blob id, so this is not a re-run the wire offers.
// Discarded is true when nothing was kept.
type CaptureStopResp struct {
BlobID string `json:"blob_id,omitempty"`
Label string `json:"label,omitempty"`
Started time.Time `json:"started,omitempty"`
Seconds float64 `json:"seconds,omitempty"`
Transcript string `json:"transcript,omitempty"`
Summary string `json:"summary,omitempty"`
Chunks int `json:"chunks,omitempty"`
NoteID int64 `json:"note_id,omitempty"`
Discarded bool `json:"discarded,omitempty"`
}
// CaptureStatusResp — what "что ты записываешь?" needs, and what /dash shows.
// Running=false with everything else empty is the normal state.
type CaptureStatusResp struct {
Running bool `json:"running"`
Label string `json:"label,omitempty"`
Started time.Time `json:"started,omitempty"`
Seconds float64 `json:"seconds,omitempty"`
Bytes int `json:"bytes,omitempty"`
}
// EnrollSpeakerReq — register a voice (Vikunja #255).
//
// Samples are separate utterances recorded deliberately for this purpose, not
// audio harvested from ordinary turns. internal/speaker requires several of
// them totalling enough seconds, and refuses one long clip: a profile built
// from a single sentence encodes that sentence as much as the person.
//
// There is no "enrol whoever just spoke" request shape, and that omission is
// the point. Taking a biometric of a guest because they walked past the
// microphone is not something a wire protocol should make easy.
type EnrollSpeakerReq struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Samples []audio.Audio `json:"samples"`
}
// Speaker — one enrolled voice as a surface sees it. The voiceprint itself is
// never sent: a listing says who is enrolled, it does not hand out the
// biometric.
type Speaker struct {
ID string `json:"id"`
Name string `json:"name"`
Enrolled time.Time `json:"enrolled"`
Samples int `json:"samples"`
// Damaged — the stored row's metadata did not read back cleanly. The
// voiceprint is still there; the name, sample count or enrolment time is
// not trustworthy. A surface should say so rather than render a corrupt
// row as a profile enrolled from zero samples, which is what a real
// minimal enrolment looks like.
Damaged bool `json:"damaged,omitempty"`
}
// EnrollSpeakerResp — the profile that was written.
type EnrollSpeakerResp struct {
Speaker Speaker `json:"speaker"`
}
// ListSpeakersResp — who is enrolled, sorted by id. Enabled is false when no
// embedding model is wired, which is this box's state: the profiles can be
// listed and deleted, nothing can be recognised.
type ListSpeakersResp struct {
Speakers []Speaker `json:"speakers"`
Enabled bool `json:"enabled"`
}
// ForgetSpeakerReq — delete one voiceprint. This is the request that must
// always work; a biometric someone asked to be rid of has to actually go.
type ForgetSpeakerReq struct {
ID string `json:"id"`
}
// SwapModelReq — load another resident model without restarting the daemon
// (Vikunja #250). ModelPath must be one of the paths in phraser.swap_models;
// anything else is ErrForbidden, and an unconfigured allowlist makes the whole
// method ErrUnknownMethod.
//
// NGpuLayers and NCtx are zero for "keep what is loaded now", which is the
// normal case — the same laptop iGPU, a different gguf.
//
// This is an owner action. It is AuthStepUp in the authority table, it is not on
// CoreAPI, and no act, intent or timer can reach it: swapping the model is not
// something Maven does to herself.
type SwapModelReq struct {
ModelPath string `json:"model_path"`
NGpuLayers int `json:"n_gpu_layers,omitempty"`
NCtx int `json:"n_ctx,omitempty"`
}
// SwapModelResp — what the daemon ended up serving. Model is the identity the
// new llama-server reported for itself, not an echo of the request: if the file
// was not the model the operator thought it was, this is where it shows.
//
// RolledBack is true when the requested model failed to load or would not answer
// and the previous one was put back. In that case the call also returns an error
// — the swap did not happen — and Model names the model still serving.
//
// NoBackend is the other failure and it is not a milder one: the rollback failed
// too, no model is loaded, and every phrasing path is on its template fallback
// with routing on the classifier. It is a separate field from RolledBack because
// the two need opposite words on the page.
type SwapModelResp struct {
Model string `json:"model"`
ModelPath string `json:"model_path"`
BaseURL string `json:"base_url"`
RolledBack bool `json:"rolled_back,omitempty"`
NoBackend bool `json:"no_backend,omitempty"`
TookMs int64 `json:"took_ms"`
}
// ModelStatusResp — which model is resident and which ones may be swapped in.
// Read-only; the authed page renders it. Swappable is the configured allowlist,
// so an empty list means the capability is off.
type ModelStatusResp struct {
Model string `json:"model"`
ModelPath string `json:"model_path"`
BaseURL string `json:"base_url"`
NGpuLayers int `json:"n_gpu_layers"`
NCtx int `json:"n_ctx"`
Swappable []string `json:"swappable,omitempty"`
}
// PingResp — the answer to MethodPing. Alive is always true (the reply itself
// is the proof); Locked says whether the daemon is still waiting for a passkey
// assertion, which is the one state where a CoreAPI read cannot tell an
// operator anything.
type PingResp struct {
Alive bool `json:"alive"`
Locked bool `json:"locked"`
}
type listTasksReq struct {
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
}
type listTasksResp struct {
Tasks []Task `json:"tasks"`
}
type setTaskStatusReq struct {
ID int64 `json:"id"`
Status string `json:"status"`
Ts time.Time `json:"ts"`
// By — the caller making the move, in the source vocabulary. Recorded on
// the row so a resolved task says what resolved it.
By string `json:"by,omitempty"`
}
// idReq — methods keyed by a single id.
type idReq struct {
ID int64 `json:"id"`
}
// markReminderReq — pending→fired|cancelled.
type markReminderReq struct {
ID int64 `json:"id"`
Status string `json:"status"`
}
// resolveNudgeReq — pending→acted|snoozed|ignored, once.
type resolveNudgeReq struct {
ID int64 `json:"id"`
Outcome string `json:"outcome"`
Ts time.Time `json:"ts"`
}
// keyReq / keySourceReq / sinceReq / outcomesReq — read param shapes.
type keyReq struct {
Key string `json:"key"`
}
type keySourceReq struct {
Key string `json:"key"`
Source string `json:"source"`
}
type sinceReq struct {
Key string `json:"key"`
Now time.Time `json:"now"`
}
type outcomesReq struct {
Rule string `json:"rule"`
N int `json:"n"`
}
type nReq struct {
N int `json:"n"`
}
// deliveryAttemptsReq — the outbox read. Status is empty for every status.
type deliveryAttemptsReq struct {
Status string `json:"status,omitempty"`
N int `json:"n"`
}
type kindNReq struct {
Kind string `json:"kind"`
N int `json:"n"`
}
type sourceNReq struct {
Prefix string `json:"prefix"`
N int `json:"n"`
}
type calendarEventsReq struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
}
type revertReq struct {
Key string `json:"key"`
}
type writeNoteReq struct {
Ts time.Time `json:"ts"`
Text string `json:"text"`
Embedding []float32 `json:"embedding"`
Source string `json:"source"`
}
type queryNotesReq struct {
Embedding []float32 `json:"embedding"`
K int `json:"k"`
}
type createReminderReq struct {
Fire time.Time `json:"fire"`
Payload string `json:"payload"`
Cron string `json:"cron"`
}
type recordNudgeReq struct {
Rule string `json:"rule"`
Channel string `json:"channel"`
Message string `json:"message"`
Ts time.Time `json:"ts"`
}
// idResp / sinceResp — small scalar return wrappers.
type idResp struct {
ID int64 `json:"id"`
}
type sinceResp struct {
Dur time.Duration `json:"dur"`
}
// Tool — an act allowlist entry as core exposes it. Status 'proposed' is an
// inert scaffold; 'enabled' is runnable. The executor only runs 'enabled'.
type Tool struct {
Name string `json:"name"`
Scope string `json:"scope"`
Cmd []string `json:"cmd"`
Destructive bool `json:"destructive"`
Status string `json:"status"`
Utterance string `json:"utterance"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
// MCPServerStatus — one configured MCP server, as the web surface sees it.
// Target is the command or url; Tools is how many tools discovery kept after
// allow_tools / max_tools, not how many the server offers.
type MCPServerStatus struct {
Name string `json:"name"`
Transport string `json:"transport"` // "stdio" (a local subprocess) or "http"
Target string `json:"target"`
Connected bool `json:"connected"`
Server string `json:"server,omitempty"` // the server's own name + version
Tools int `json:"tools"`
Err string `json:"err,omitempty"`
}
// chatReq / chatResp — text chat round-trip for the IPC Chat method.
//
// Conversation names the thread this utterance belongs to: a mavweb session, a
// telegram chat. It is opaque to the daemon and only has to be stable for one
// conversation and distinct across them. Empty is allowed and means "the
// unattributed text tap", which is what an old client sends.
type chatReq struct {
Text string `json:"text"`
Conversation string `json:"conversation,omitempty"`
}
type chatResp struct {
Reply string `json:"reply"`
}
type proposeToolReq struct {
Name string `json:"name"`
Scope string `json:"scope"`
Utterance string `json:"utterance"`
Ts time.Time `json:"ts"`
}
type proposeToolResp struct {
Proposed bool `json:"proposed"`
}
type enableToolReq struct {
Name string `json:"name"`
Scope string `json:"scope"`
Cmd []string `json:"cmd"`
Destructive bool `json:"destructive"`
Ts time.Time `json:"ts"`
}
type disableToolReq struct {
Name string `json:"name"`
}
type lookupToolReq struct {
Name string `json:"name"`
}
type listToolsReq struct {
Status string `json:"status"`
}
type listToolsResp struct {
Tools []Tool `json:"tools"`
}
// ProposedRoutine — a detected pattern awaiting human confirmation.
type ProposedRoutine struct {
ID int64 `json:"id"`
Action string `json:"action"`
Object string `json:"object"`
IntervalDays float64 `json:"interval_days"`
Status string `json:"status"` // proposed | accepted | dismissed
CreatedTs int64 `json:"created_ts"`
ReminderID *int64 `json:"reminder_id,omitempty"`
}
type listProposedRoutinesResp struct {
Routines []ProposedRoutine `json:"routines"`
}
type dismissProposedRoutineReq struct {
ID int64 `json:"id"`
}
type acceptProposedRoutineReq struct {
ID int64 `json:"id"`
}
// IntakeEvent — one entry of the unified intake journal on the wire. Mirrors
// event.Event field for field; the ipc package does not import internal/event
// so the wire shape stays independent of the in-process type.
type IntakeEvent struct {
Source string `json:"source"`
Kind string `json:"kind"`
EntityIDs []string `json:"entity_ids,omitempty"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
Priority string `json:"priority"`
OccurredAt time.Time `json:"occurred_at"`
// NoticedAt — when the journal took it. This is the order the ring returns
// and the one a page must sort and label by; OccurredAt is when the thing
// happened, which for a cold feed read is a week before it arrived.
NoticedAt time.Time `json:"noticed_at"`
}
// --- Rule trace / explanation DTOs ---
// RuleTrace — per-rule evaluation result for one tick.
type RuleTrace struct {
RuleName string `json:"rule_name"`
Severity int `json:"severity"`
PredicateResult bool `json:"predicate_result"`
GateResult bool `json:"gate_result"`
GateBlockedBy string `json:"gate_blocked_by,omitempty"`
GateDetail GateDetail `json:"gate_detail,omitempty"`
WasSelected bool `json:"was_selected"`
LostTo string `json:"lost_to,omitempty"`
}
// GateDetail — snapshot of the values the gate checked.
type GateDetail struct {
SnoozeUntil *time.Time `json:"snooze_until,omitempty"`
CooldownUntil *time.Time `json:"cooldown_until,omitempty"`
QuietHours bool `json:"quiet_hours"`
CalendarBusy bool `json:"calendar_busy"`
Presence string `json:"presence"`
InertKeysMissing []string `json:"inert_keys_missing,omitempty"`
}
// TickTrace — snapshot of one tick's rule evaluations.
type TickTrace struct {
Now time.Time `json:"now"`
Winner string `json:"winner"`
Rules []RuleTrace `json:"rules"`
}
// MorningRoutineItem — one checklist entry's current state.
type MorningRoutineItem struct {
Key string `json:"key"`
Label string `json:"label"`
Done bool `json:"done"`
}
// MorningRoutineStatus — one routine's checklist state right now.
type MorningRoutineStatus struct {
Name string `json:"name"`
Active bool `json:"active"`
WindowStart string `json:"window_start"`
WindowEnd string `json:"window_end"`
Items []MorningRoutineItem `json:"items"`
}
// DayPlanItem — one line of the day plan. Kind is "event", "reminder" or
// "checklist"; Uncertain marks an item whose provenance is below a full
// calendar read (a meeting relayed off a phone notification), so a UI can hedge
// the same way the spoken form does.
type DayPlanItem struct {
At time.Time `json:"at"`
Text string `json:"text"`
Kind string `json:"kind"`
Uncertain bool `json:"uncertain,omitempty"`
}
// DayPlan — the plan for one calendar day. Spoken is the RU sentence maven
// says when asked, rendered core-side so the voice reply and the web view can
// never drift apart.
type DayPlan struct {
Date time.Time `json:"date"`
Items []DayPlanItem `json:"items"`
Spoken string `json:"spoken"`
}
// storeEncryptionKeyReq — the passkey-derived secret used to wrap the store
// encryption key. Called by mavweb after a verified assertion.
//
// Secret is the 32-byte WebAuthn PRF output, NOT the credential public key.
// The field used to carry the public key and that was the bug: a public key
// sits in passkeys.json next to the wrapped blob, so the blob protected
// nothing. See internal/webauthn/keywrap.go.
//
// Explicit says the operator asked for the cold-start key to be written, as
// opposed to it being a side effect of asserting a passkey. Without the flag
// the daemon writes only when no blob exists yet. Rewriting on every assertion
// is what let a page-level compromise substitute its own PRF value and have
// the daemon re-wrap the real database key under it, and what let a second
// authenticator silently replace the first one's blob.
type storeEncryptionKeyReq struct {
Secret []byte `json:"secret"`
Explicit bool `json:"explicit,omitempty"`
}
// unlockReq — the passkey-derived secret for unwrapping the store encryption
// key at cold-start. mavend reads the wrapped blob from its own configured
// path; this is the other half. Same PRF-output contract as above.
type unlockReq struct {
Secret []byte `json:"secret"`
}
// ErrToolNotFound — no tool row with this name (re-exported store sentinel for
// wire round-tripping via errors.Is).
var ErrToolNotFound = errors.New("ipc: tool not found")
// callerKey — context key for the authenticated caller. Server sets it from
// SO_PEERCRED before dispatch; in-process callers omit it (the adapter treats
// a missing Caller as "trusted same-process", the equivalent of the socket's
// 0600 floor).
type callerKey struct{}
// Caller — the peer identity as core sees it. Uid/Pid come from SO_PEERCRED
// on Linux; the future auth layer maps Uid + module enrollment → authority.
// Today only Uid is populated and used for a same-user check.
type Caller struct {
Uid int32
Pid int32
}
// WithCaller returns ctx annotated with c. Server-side use only.
func WithCaller(ctx context.Context, c Caller) context.Context {
return context.WithValue(ctx, callerKey{}, c)
}
// CallerFrom retrieves the Caller, or ok=false if absent (in-process path).
func CallerFrom(ctx context.Context) (Caller, bool) {
c, ok := ctx.Value(callerKey{}).(Caller)
return c, ok
}
// Sentinel errors. Mirror store's 1:1 so module code reads the same whether
// in-process or over the wire. The store adapter translates store.* → these.
var (
ErrNoFact = errors.New("ipc: no fact for key")
ErrConfidence = errors.New("ipc: confidence must be in (0.0, 1.0]")
ErrVoidsMissing = errors.New("ipc: voids_id does not reference an existing fact")
ErrNudgeNotFound = errors.New("ipc: nudge not found")
ErrNudgeOutcome = errors.New("ipc: nudge already resolved")
ErrReminderNotFound = errors.New("ipc: reminder not found")
ErrReminderState = errors.New("ipc: reminder not in a mutable state")
ErrUnknownMethod = errors.New("ipc: unknown method")
ErrBadParams = errors.New("ipc: bad params")
// ErrForbidden — the caller's authority doesn't cover this call. The
// auth layer's only wire-exported verdict: surface caps the layer, or a
// write was out-of-scope, or step-up was required but not asserted. The
// text ErrForbidden carries is derived during dispatch (from auth.ErrForbidden
// via fmt.Errorf %w wrapping); the wire carries codeForbidden.
ErrForbidden = errors.New("ipc: forbidden")
)