76a251a20d
# Conflicts: # internal/store/migrations.go
935 lines
39 KiB
Go
935 lines
39 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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
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.
|
|
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)
|
|
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
|
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
|
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
|
|
|
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
|
// own table so machine-rate traces never crowd out human-rate facts.
|
|
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, 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)
|
|
// RecentNotesFromSource — the newest n notes whose source starts with
|
|
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
|
|
// from recall, so this is the only way to reach them, and it keeps the feed
|
|
// answer from being crowded out of a fixed window by his own notes.
|
|
RecentNotesFromSource(ctx context.Context, prefix string, 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, by string) 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"`
|
|
// 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")
|
|
)
|