2ca5ffa4f9
capture_stop held the IPC request open for the whole map reduce, up to twenty minutes. A voice turn that says "хватит" waited for forty model calls before Maven said anything. Stop now returns the transcript and the summary runs on a goroutine in the daemon's WaitGroup, on the daemon context so a client that hung up does not cancel the only readable record of the meeting. With no summary and save_transcript false, writeNotes wrote nothing at all: an hour of meeting left a blob that prunes in seven days and no trace in the note store. The transcript is written instead when the summary is missing. That flag decides whether the verbatim record is kept in addition to a summary, not whether the meeting is remembered. The wire carries the session token now, and the contract comments say what the code does: the summary is usually absent from the stop response, and re running a stored blob is a manual job because no method takes a blob id. The save_transcript comment says the cost is recall corpus rather than disk. Found in review of #73.
844 lines
34 KiB
Go
844 lines
34 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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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"`
|
|
Status string `json:"status"`
|
|
Due *time.Time `json:"due,omitempty"`
|
|
Weight int `json:"weight,omitempty"`
|
|
Resolved *time.Time `json:"resolved,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 must NOT set Status "open" — work Maven inferred from something she
|
|
// read is a suggestion until the owner confirms it on the /tasks page. Capture
|
|
// is idempotent on normalised text among live tasks, so re-reading the same
|
|
// mailbox is free.
|
|
type CaptureTaskReq struct {
|
|
Text string `json:"text"`
|
|
Source string `json:"source"`
|
|
Evidence string `json:"evidence,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"`
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// 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"`
|
|
}
|
|
|
|
// 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.
|
|
type SwapModelResp struct {
|
|
Model string `json:"model"`
|
|
ModelPath string `json:"model_path"`
|
|
BaseURL string `json:"base_url"`
|
|
RolledBack bool `json:"rolled_back,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"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
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.
|
|
type chatReq struct {
|
|
Text string `json:"text"`
|
|
}
|
|
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"`
|
|
}
|
|
|
|
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
|
// - the in-process store adapter (server.go storeAPI) — used by the daemon
|
|
// for modules that live in-process for now (router, delivery) and by tests,
|
|
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
|
|
// - the client proxy (client.go) — same interface, over the wire.
|
|
//
|
|
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
|
|
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
|
|
// That swappability is the seam the auth layer will insert into without
|
|
// touching the module code.
|
|
type CoreAPI interface {
|
|
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
|
|
LatestFact(ctx context.Context, key string) (Fact, error)
|
|
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
|
|
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
|
|
Presence(ctx context.Context) (Presence, error)
|
|
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
|
|
MarkReminder(ctx context.Context, id int64, status string) error
|
|
ListReminders(ctx context.Context, n int) ([]Reminder, error)
|
|
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
|
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
|
|
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
|
|
RecentFacts(ctx context.Context, n int) ([]Fact, error)
|
|
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
|
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
|
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
|
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
|
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
|
|
|
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
|
// returns whether a new proposal was written. EnableTool fills cmd +
|
|
// destructive and flips status to 'enabled'. DisableTool reverts an
|
|
// enabled tool back to proposed (it stays in the store, won't run).
|
|
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
|
|
// ProposeTool is maven-callable (no step-up — she has no passkey).
|
|
// LookupTool/ListTools read them.
|
|
// scope defaults to "homelab" when empty.
|
|
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
|
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
|
|
DisableTool(ctx context.Context, name string) error
|
|
DeleteTool(ctx context.Context, name string) error
|
|
LookupTool(ctx context.Context, name string) (Tool, error)
|
|
ListTools(ctx context.Context, status string) ([]Tool, error)
|
|
RevertFact(ctx context.Context, key string) (int64, error)
|
|
|
|
// ListProposedRoutines returns proposed routines, newest first.
|
|
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
|
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
|
DismissProposedRoutine(ctx context.Context, id int64) error
|
|
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
|
|
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
|
AcceptProposedRoutine(ctx context.Context, id int64) error
|
|
|
|
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
|
// intake seam for the voice path, the web form and the future email
|
|
// extractor. Idempotent per live normalised text; the response says
|
|
// whether a row was actually created.
|
|
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
|
// ListTasks returns tasks in one status, newest first. "" is every row,
|
|
// "live" is candidate + open (outstanding work).
|
|
ListTasks(ctx context.Context, status string) ([]Task, error)
|
|
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
|
// open→done|dropped. Any other move is refused.
|
|
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error
|
|
|
|
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
|
// this after every tick; the store adapter returns an error (trace is not
|
|
// persisted — it's a daemon-level cache).
|
|
TickTrace(ctx context.Context) (TickTrace, error)
|
|
|
|
// MorningStatus returns each configured morning routine's current
|
|
// checklist state (see internal/morning): active today/now, which items
|
|
// are done, which are still missing. The store adapter returns an error
|
|
// (morning routines are daemon-config, not persisted) — same shape as
|
|
// TickTrace.
|
|
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
|
|
|
// MCPServers reports the configured MCP servers and their health
|
|
// (Vikunja #251). Read-only introspection for /tools — there is no
|
|
// "call this tool" method on purpose: an MCP tool runs through the same
|
|
// allowlist, confirm turn and act path as any other tool, and a second
|
|
// mutation path would be a second thing to get wrong. Empty when the
|
|
// mcp config block is absent, which is the default.
|
|
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
|
|
|
|
// DayPlan returns today's ordered plan — calendar events, pending
|
|
// reminders and any morning checklist still outstanding (see
|
|
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
|
// Read-only: asking for the plan never dispatches or schedules anything.
|
|
// The store adapter returns an error (the plan needs the daemon's routine
|
|
// config) — same shape as TickTrace and MorningStatus.
|
|
DayPlan(ctx context.Context) (DayPlan, error)
|
|
|
|
// Chat routes a text utterance through the reactive handler's core path
|
|
// (router → dialogue → action → replier) and returns the reply text.
|
|
// No audio or stt/tts — for text channels (mavweb, telegram).
|
|
Chat(ctx context.Context, text string) (string, error)
|
|
|
|
// RecentEvents returns the daemon's unified intake journal, newest first
|
|
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
|
// it came from: a relayed notification, a mail candidate, a feed item, a
|
|
// changed page, a spend, a presence probe.
|
|
//
|
|
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
|
|
// the store adapter returns an error, because the journal is a bounded
|
|
// in-memory ring and not a table. Its contents are a window over intake,
|
|
// never the durable record — that is still the fact, note or task the
|
|
// intake path wrote.
|
|
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// --- 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 at enrollment time. Called by mavweb after RegisterFinish.
|
|
//
|
|
// 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.
|
|
type storeEncryptionKeyReq struct {
|
|
Secret []byte `json:"secret"`
|
|
}
|
|
|
|
// 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")
|
|
)
|