Files
Maven/internal/ipc/wire.go
T
kami f42d1594ef Turn a mail into task candidates, and into nothing else (#246)
The extraction half. internal/email.Extractor asks the resident Qwen3-1.7B,
under a GBNF grammar, what one message requires of him, and returns at most
three short candidates with an optional date.

Everything it can produce is a row in `tasks` with status "candidate",
written through the intake seam #130 built for exactly this (Source
"email:<mailbox>", Evidence = the subject line). No reminder, no fact, no
note, no calendar event. That bound is the design: a reminder FIRES, so a
1.7B misreading "встреча была в четверг" as a future appointment would wake
him up about it, whereas a wrong candidate is a line he dismisses in one
click. A due date the model read out of the mail is stored on the candidate,
where no scheduler reads it — the review page sorts by it. Relative wording
("до пятницы") is deliberately left in the text rather than resolved to a
date the model would get wrong.

The prompt is written against the two things a small model does here: it
summarises when asked to extract, and it invents an obligation out of a
polite closing line. Hence the demand for a verb phrase, and an explicit
empty array — most mail contains no task, and a model with no way to say
"nothing" says something.

Wiring: core owns extraction because llama-server lives in core's process,
so the reader hands messages over a new ipc.MethodIngestMail. It is a Server
hook (like StepUp/UnlockFn), not a CoreAPI method — not a store operation,
and no CoreAPI implementation should have to carry it. The hook stays nil
without an `email` config block or without a llama-server phraser, so the
method answers ErrUnknownMethod: off unless configured, twice over. There is
no keyword fallback on purpose — "the subject became a task" is a mailbox
rendered as a to-do list, not extraction.

Privacy: junk is refused before the model is called, mail text is never
search input, extraction errors carry byte counts rather than the reply, the
stored evidence is a truncated subject, and the log line names the mailbox
and the UID only.
2026-08-01 03:06:55 +04:00

150 lines
5.6 KiB
Go

package ipc
import (
"encoding/json"
"errors"
"fmt"
)
// Method — one RPC verb. The set is intentionally small: it mirrors exactly
// what a module legitimately needs from core state, and nothing more. Adding
// a method is a core-authority change (every method is a new thing a module
// can ask for); do it deliberately.
type Method string
const (
MethodWriteFact Method = "write_fact"
MethodLatestFact Method = "latest_fact"
MethodLatestFactBySource Method = "latest_fact_by_source"
MethodSince Method = "since"
MethodPresence Method = "presence"
MethodCreateReminder Method = "create_reminder"
MethodMarkReminder Method = "mark_reminder"
MethodListReminders Method = "list_reminders"
MethodRecordNudge Method = "record_nudge"
MethodResolveNudge Method = "resolve_nudge"
MethodRecentOutcomes Method = "recent_outcomes"
MethodRecentFacts Method = "recent_facts"
MethodCalendarEvents Method = "calendar_events"
MethodRecentNudges Method = "recent_nudges"
MethodWriteNote Method = "write_note"
MethodQueryNotes Method = "query_notes"
MethodRecentNotes Method = "recent_notes"
MethodProposeTool Method = "propose_tool"
MethodEnableTool Method = "enable_tool"
MethodDisableTool Method = "disable_tool"
MethodAssertStepUp Method = "assert_stepup"
MethodStoreEncryptionKey Method = "store_encryption_key"
MethodUnlock Method = "unlock"
MethodLookupTool Method = "lookup_tool"
MethodListTools Method = "list_tools"
MethodDeleteTool Method = "delete_tool"
MethodListProposedRoutines Method = "list_proposed_routines"
MethodDismissProposedRoutine Method = "dismiss_proposed_routine"
MethodAcceptProposedRoutine Method = "accept_proposed_routine"
MethodRevertFact Method = "revert_fact"
MethodTickTrace Method = "tick_trace"
MethodMorningStatus Method = "morning_status"
MethodDayPlan Method = "day_plan"
MethodChat Method = "chat"
MethodCaptureTask Method = "capture_task"
MethodListTasks Method = "list_tasks"
MethodSetTaskStatus Method = "set_task_status"
MethodIngestMail Method = "ingest_mail"
)
// Request — one frame from module to core. Params is the JSON-encoded argument
// struct for Method (see api.go for the per-method shapes). The server
// unmarshals Params based on Method; an unknown Method ⇒ ErrUnknownMethod.
type Request struct {
Method Method `json:"m"`
Params json.RawMessage `json:"p,omitempty"`
}
// Response — one frame from core back to module. Exactly one of Result/Error
// is set. Result is the JSON-encoded return value of the method (might be a
// scalar, a struct, or null for void methods).
type Response struct {
Result json.RawMessage `json:"r,omitempty"`
Error *RpcError `json:"e,omitempty"`
}
// RpcError — a typed wire error. Code is one of the sentinel codes below;
// the client rehydrates it into the matching package sentinel so callers can
// use errors.Is like they would in-process (core's contract is the same on
// both sides of the wire — the boundary shouldn't change error semantics).
type RpcError struct {
Code string `json:"c"`
Message string `json:"m,omitempty"`
}
func (e *RpcError) Error() string {
if e.Message != "" {
return fmt.Sprintf("ipc: %s: %s", e.Code, e.Message)
}
return fmt.Sprintf("ipc: %s", e.Code)
}
// Sentinel codes. Stable over the wire — do not rename. Mirror the package
// sentinels in api.go 1:1. The string is the contract.
const (
codeNoFact = "no_fact"
codeConfidence = "confidence"
codeVoidsMissing = "voids_missing"
codeNudgeNotFound = "nudge_not_found"
codeNudgeOutcome = "nudge_outcome"
codeReminderMissing = "reminder_not_found"
codeReminderState = "reminder_state"
codeToolNotFound = "tool_not_found"
codeUnknownMethod = "unknown_method"
codeBadParams = "bad_params"
codeForbidden = "forbidden"
codeInternal = "internal"
)
// codeOf maps a server-side sentinel to its wire code. Anything not matched
// is codeInternal — we never leak internal Go error text to a module; it
// gets a generic "internal" and the daemon logs the real error server-side.
func codeOf(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, ErrNoFact):
return codeNoFact
case errors.Is(err, ErrConfidence):
return codeConfidence
case errors.Is(err, ErrVoidsMissing):
return codeVoidsMissing
case errors.Is(err, ErrNudgeNotFound):
return codeNudgeNotFound
case errors.Is(err, ErrNudgeOutcome):
return codeNudgeOutcome
case errors.Is(err, ErrReminderNotFound):
return codeReminderMissing
case errors.Is(err, ErrReminderState):
return codeReminderState
case errors.Is(err, ErrToolNotFound):
return codeToolNotFound
case errors.Is(err, ErrUnknownMethod):
return codeUnknownMethod
case errors.Is(err, ErrBadParams):
return codeBadParams
case errors.Is(err, ErrForbidden):
return codeForbidden
default:
return codeInternal
}
}
// rpcErr builds the wire error for a server-side error. message is omitted
// for sentinel codes (the Code carries the meaning; no need to echo text the
// caller can re-derive from errors.Is) and included for internal/bad-params
// where the text is the actual diagnostic.
func rpcErr(err error) *RpcError {
c := codeOf(err)
if c == codeInternal || c == codeBadParams {
return &RpcError{Code: c, Message: err.Error()}
}
return &RpcError{Code: c}
}