Compare commits
24 Commits
70b32af8a7
...
b46bab99f7
| Author | SHA1 | Date | |
|---|---|---|---|
| b46bab99f7 | |||
| b9ee858421 | |||
| c600b426f3 | |||
| b95566aa98 | |||
| cb3b507ed5 | |||
| 7262310fce | |||
| 43dc487113 | |||
| 22a8eed1c3 | |||
| 1e35a10f33 | |||
| 4f7ad7d99a | |||
| 01b47e3864 | |||
| 12c2ae1d17 | |||
| 4761c20ad6 | |||
| ed0331c774 | |||
| 968477ea59 | |||
| 82d0384020 | |||
| 9ed259660d | |||
| c8a5b5416e | |||
| e4f0508a2f | |||
| 5fede2acb7 | |||
| b49755302c | |||
| c0b99828f9 | |||
| 52548abe02 | |||
| 521c315b30 |
@@ -292,9 +292,14 @@ in `runTurn` means adding its name to `preRouteLadder` in
|
||||
|
||||
## LLM output contract
|
||||
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||||
`internal/phraser/llmphraser.go`), with fallback to plain text and the legacy
|
||||
`{"body","summary"}`. Mood is a fixed enum. Router prompt is a separate contract:
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}`, with fallback to plain text when
|
||||
the model skips the JSON. **One parser, `parseResponseMood` in
|
||||
`internal/phraser/parse.go`**, and every path reaches it: the six `LLMPhraser` methods,
|
||||
`PhraseWorld`, and `Replier.PhraseReply`, which `cmd/mavend/replier_llm.go` wraps — that file
|
||||
holds the stub fallback and no parsing of its own. The legacy `{"body","summary"}` fallback
|
||||
was deleted on 2026-08-06 (V-397): it was the contract before `{"response","mood"}` replaced
|
||||
it, no prompt asks for that shape, the GBNF cannot emit it, and no test covered it.
|
||||
Mood is a fixed enum. Router prompt is a separate contract:
|
||||
`[{"intent":<enum>, key?, value?, text?, verb?}, ...]`, 7 intents (`fact, reminder,
|
||||
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
||||
workspace enforces that the Go and relabelling prompts remain identical.
|
||||
|
||||
+171
-21
@@ -6,6 +6,8 @@ import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
@@ -65,8 +67,28 @@ var clarifyExpiredVariants = []string{
|
||||
"Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.",
|
||||
}
|
||||
|
||||
// clarifyExpiredLine picks one of them at random.
|
||||
func clarifyExpiredLine() string {
|
||||
// clarifyExpiredPluralVariants — the same notice when TWO parked requests died
|
||||
// together (Vikunja #561). Since a side query suspends the flow instead of
|
||||
// dropping it, the stack can hold both the flow and the thing he interrupted it
|
||||
// with, and TakeExpired drops the whole stack when the top times out. "Прошлую
|
||||
// просьбу" would then be a lie about the count: he loses two and hears about
|
||||
// one.
|
||||
//
|
||||
// Two phrasings only, against five for the singular. This fires when he walks
|
||||
// off in the middle of an interrupted exchange, which is rarer than walking off
|
||||
// in the middle of a plain one, so it repeats less and needs less variety.
|
||||
var clarifyExpiredPluralVariants = []string{
|
||||
"Прости, я слишком долго ждала и отпустила обе прошлые просьбы. Если они ещё нужны, скажи заново.",
|
||||
"Я не дождалась ответа и убрала обе прошлые просьбы. Повтори, если они всё ещё нужны.",
|
||||
}
|
||||
|
||||
// clarifyExpiredLine picks one of them at random. n is how many requests died;
|
||||
// anything above one gets the plural wording, because the bound is two today and
|
||||
// a third would still be "обе" short of the truth only if MaxStackDepth grew.
|
||||
func clarifyExpiredLine(n int) string {
|
||||
if n > 1 {
|
||||
return clarifyExpiredPluralVariants[rand.Intn(len(clarifyExpiredPluralVariants))]
|
||||
}
|
||||
return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))]
|
||||
}
|
||||
|
||||
@@ -74,23 +96,34 @@ func clarifyExpiredLine() string {
|
||||
// notice is glued in front of this turn's reply (see withNotice), so a caller
|
||||
// checking for it has to match a prefix, not the whole string.
|
||||
func isClarifyExpired(s string) bool {
|
||||
for _, v := range clarifyExpiredVariants {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
_, ok := cutClarifyExpired(s)
|
||||
return ok
|
||||
}
|
||||
|
||||
// trimClarifyExpired strips a leading expiry notice, leaving this turn's actual
|
||||
// reply. "" ⇒ the notice was the whole thing.
|
||||
func trimClarifyExpired(s string) string {
|
||||
for _, v := range clarifyExpiredVariants {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(s, v))
|
||||
rest, ok := cutClarifyExpired(s)
|
||||
if !ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// cutClarifyExpired matches either expiry deck as a prefix and returns what
|
||||
// follows it. Both decks, since V-561 added the plural line: a caller asking
|
||||
// "did she say a request timed out" means the fact, not which wording carried
|
||||
// it, and a helper that knew only the singular would read the two-request
|
||||
// notice as ordinary reply text.
|
||||
func cutClarifyExpired(s string) (string, bool) {
|
||||
for _, deck := range [][]string{clarifyExpiredVariants, clarifyExpiredPluralVariants} {
|
||||
for _, v := range deck {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(s, v)), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// clarifyExpiredNotice returns that line when a parked question had just timed
|
||||
@@ -101,11 +134,12 @@ func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
|
||||
if h.clarifyStore == nil {
|
||||
return ""
|
||||
}
|
||||
if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) {
|
||||
n := h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now())
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
||||
return clarifyExpiredLine()
|
||||
log.Printf("voice: clarify — %d parked question(s) expired, telling him and routing the words fresh", n)
|
||||
return clarifyExpiredLine(n)
|
||||
}
|
||||
|
||||
// withNotice glues the expiry notice in front of this turn's reply. One turn
|
||||
@@ -121,6 +155,48 @@ func withNotice(notice, reply string) string {
|
||||
return notice + " " + reply
|
||||
}
|
||||
|
||||
// withResumed puts the resumed question on the END of this turn's reply, where
|
||||
// withNotice puts the expiry notice on the front (Vikunja #561).
|
||||
//
|
||||
// The order is the owner's: "в Риме сейчас ..., на какое время поставить
|
||||
// напоминание?" — answer first, then the open question. A question in front of
|
||||
// its own answer would read as ignoring what he asked.
|
||||
//
|
||||
// A statement's full stop is folded into a comma, so the two acts read as one
|
||||
// sentence — that is the owner's own punctuation, "в Риме сейчас ..., на какое
|
||||
// время поставить напоминание?". An answer that is ITSELF a question keeps its
|
||||
// mark and the resume starts a new sentence: she sometimes answers a side query
|
||||
// by asking him to say it again, and "переформулировать?, на какое время" folds
|
||||
// two questions into one unreadable line.
|
||||
//
|
||||
// A resume with no answer in front of it is just the question.
|
||||
func withResumed(reply, resumed string) string {
|
||||
if resumed == "" {
|
||||
return reply
|
||||
}
|
||||
reply = strings.TrimSpace(reply)
|
||||
if reply == "" {
|
||||
return resumed
|
||||
}
|
||||
if strings.HasSuffix(reply, "?") {
|
||||
return reply + " " + resumed
|
||||
}
|
||||
if trimmed := strings.TrimRight(reply, ".!"); trimmed != "" {
|
||||
reply = trimmed
|
||||
}
|
||||
return reply + ", " + lowerFirst(resumed)
|
||||
}
|
||||
|
||||
// lowerFirst lowercases the opening rune, so a deck line written as a standalone
|
||||
// sentence reads as the second half of one. Only the first rune: "На какое
|
||||
// время" must become "на какое время" and nothing else in it may move.
|
||||
func lowerFirst(s string) string {
|
||||
for i, r := range s {
|
||||
return string(unicode.ToLower(r)) + s[i+utf8.RuneLen(r):]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// missingFor returns the slots a decision still needs, most important first.
|
||||
// Empty ⇒ there is nothing identifiable to ask about.
|
||||
func missingFor(dec router.Decision) []dialogue.Slot {
|
||||
@@ -161,7 +237,7 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
|
||||
log.Printf("voice: clarify — act %q matched no capability; saying so instead of asking", dec.Utterance)
|
||||
return actNotRecognized, true
|
||||
}
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
q := &dialogue.PendingQuestion{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: toDialogueSlots(dec.Slots),
|
||||
Missing: []dialogue.Slot{slot},
|
||||
@@ -170,7 +246,33 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
|
||||
TTL: clarifyTTL,
|
||||
Attempts: 1, // this ask
|
||||
MaxAttempts: h.clarifyMaxAttempts,
|
||||
})
|
||||
}
|
||||
// Push, not Put, when this turn suspended a flow (Vikunja #561): the side
|
||||
// query needs clarifying of ITS own, and Put would replace the top of the
|
||||
// stack — which is the very question the side query was allowed to interrupt
|
||||
// rather than kill. Push keeps both.
|
||||
//
|
||||
// Push returns whatever the depth bound forced out, and that one has to be
|
||||
// spoken: MaxStackDepth is a promise that every level she keeps is a level
|
||||
// she can name when it dies. It is glued in front, like every other notice
|
||||
// about something let go.
|
||||
rt := turnRouteFrom(ctx)
|
||||
if rt != nil && rt.suspended {
|
||||
if evicted := h.clarifyStore.Push(dialogueIDOf(ctx), q); evicted != nil {
|
||||
log.Printf("voice: clarify — stack full at %d, letting go of the request behind %q", dialogue.MaxStackDepth, evicted.Utterance)
|
||||
rt.dropped = withNotice(rt.dropped, clarifyDropped)
|
||||
}
|
||||
// One question per breath still holds. The side query turned out to need
|
||||
// a question of its own, so THAT is the one she asks; resuming as well
|
||||
// would put two questions in one reply, which is the interrogation
|
||||
// askRemainingGap already refuses to run. The suspended flow keeps its
|
||||
// place underneath and is not lost — if it is never reached it dies on
|
||||
// the TTL, and the expiry notice (now plural-aware) says so.
|
||||
rt.resume = ""
|
||||
log.Printf("voice: clarify — asked about %s for intent=%s, stacked on a suspended flow", slot, dec.Intent)
|
||||
return question, true
|
||||
}
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent)
|
||||
return question, true
|
||||
}
|
||||
@@ -180,10 +282,17 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
|
||||
// self-reference ("отменила"), as everywhere.
|
||||
const clarifyCancelled = "Хорошо, отменила."
|
||||
|
||||
// clarifyDropped — he asked for something else instead, so the parked request
|
||||
// clarifyDropped — he asked for something ELSE instead, so the parked request
|
||||
// is gone. Glued in front of the answer to what he actually asked, because
|
||||
// nothing may be dropped in silence. V-561 suspends and resumes it instead of
|
||||
// letting it go, and this line goes away with it.
|
||||
// nothing may be dropped in silence.
|
||||
//
|
||||
// Only new_request and cancel reach this since V-561. A side query used to as
|
||||
// well, and the owner rejected it on sight: he asks about the weather in the
|
||||
// middle of setting a reminder, and hearing "прошлую просьбу отпускаю" tells him
|
||||
// a thing he did not ask to lose has been lost. It had not been — there was
|
||||
// simply nowhere to put it. Now there is (ClarifyStore's stack), so a side query
|
||||
// suspends and resumes, and apologising for a drop that did not happen is worse
|
||||
// than saying nothing.
|
||||
const clarifyDropped = "Прошлую просьбу отпускаю."
|
||||
|
||||
// resolveClarifyAnswer reads an utterance against the parked question and
|
||||
@@ -227,7 +336,19 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
case roleCancel:
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
return clarifyCancelled, true
|
||||
case roleSideQuery, roleNewRequest:
|
||||
case roleSideQuery:
|
||||
// He asked something of his own WITHOUT leaving the flow. The question
|
||||
// stays exactly where it is — same slot, same attempt, same parked
|
||||
// utterance — and these words go on to be answered as themselves. The
|
||||
// resumed question is then glued onto the back of that answer, so one
|
||||
// reply carries both acts (Vikunja #561).
|
||||
//
|
||||
// No attempt is spent. He answered the side query, not the parked
|
||||
// question, and charging a retry for a turn that was never an answer is
|
||||
// the V-554 shape.
|
||||
h.noteSuspended(ctx, q)
|
||||
return "", false
|
||||
case roleNewRequest:
|
||||
// He moved on. A parked question used to swallow whatever came next, so
|
||||
// one act she could not fulfil ate the following three turns (Vikunja
|
||||
// #554) and a world question set a reminder for a time nobody asked for
|
||||
@@ -283,6 +404,35 @@ func (h *reactiveHandler) noteDropped(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// noteSuspended keeps the parked question alive across a side query and records
|
||||
// the words that bring it back, so runTurn can put them after this turn's answer
|
||||
// (Vikunja #561).
|
||||
//
|
||||
// Two things happen to the question and neither is an attempt. Its clock is
|
||||
// restarted, because she is about to ask it again and the 90s TTL measures the
|
||||
// pause since she last spoke it — leaving Asked at the original ask would let a
|
||||
// flow he is actively working through die of a wait he did not take. And the
|
||||
// stack is left exactly as it is: the question is already on top, so suspending
|
||||
// it is not a write.
|
||||
//
|
||||
// A slot with no resumed wording (clarifyResumedFor says so) resumes nothing and
|
||||
// says nothing. She must not claim to be holding a question she cannot re-ask.
|
||||
func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.PendingQuestion) {
|
||||
rt := turnRouteFrom(ctx)
|
||||
if rt == nil || len(q.Missing) == 0 {
|
||||
return
|
||||
}
|
||||
question, ok := clarifyResumedFor(q.Missing[0])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q.Asked = h.now()
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
rt.resume = question
|
||||
rt.suspended = true
|
||||
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply", q.Missing[0])
|
||||
}
|
||||
|
||||
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
||||
// unless they already carry it. "напомни" + "позвонить маме" reads as the
|
||||
// request he would have made in one breath. Nothing is appended when the
|
||||
|
||||
@@ -581,8 +581,16 @@ func TestClarifyStepsAsideForItsOwnRequest(t *testing.T) {
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "кто изобрёл телефон"); handled {
|
||||
t.Fatalf("a world question must route as itself, got %q", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Error("the parked question must be dropped, not left to eat the turn after this one")
|
||||
// Not eating the turn is `handled == false` above, and that is the whole of
|
||||
// #554. Since V-561 the question also SURVIVES it: a side query suspends the
|
||||
// flow rather than ending it, so the reminder is still there and still on the
|
||||
// attempt it was parked with.
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil {
|
||||
t.Fatal("a side query must suspend the parked question, not drop it")
|
||||
}
|
||||
if q.Attempts != 1 {
|
||||
t.Errorf("a turn that was never an answer spent an attempt: %d, want 1", q.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,38 @@ var clarifyQuestionVariants = map[dialogue.Slot][]string{
|
||||
},
|
||||
}
|
||||
|
||||
// clarifyResumedVariants — the wording for a question coming BACK after a side
|
||||
// query took the turn away from it (Vikunja #561).
|
||||
//
|
||||
// It is not the first question again. "Когда?" works in the same breath as
|
||||
// "напомни позвонить маме", because the thing it is about was just said. After
|
||||
// a turn about the weather in Rome it does not: he has been thinking about
|
||||
// something else, and a bare "Когда?" asks him to remember what she is holding.
|
||||
// So the resumed form names the request — "напоминание", "заметка" — and the
|
||||
// first form stays short.
|
||||
//
|
||||
// One wording per slot, not a rotation and not an attempt ladder. A resume does
|
||||
// not spend an attempt (that is the point of suspending rather than re-asking),
|
||||
// so there is no attempt number to vary on, and this line is heard once per
|
||||
// interruption rather than repeatedly.
|
||||
//
|
||||
// Persona holds: infinitive, so no gender agreement, "ты" nowhere needed, no pet
|
||||
// names.
|
||||
var clarifyResumedVariants = map[dialogue.Slot]string{
|
||||
dialogue.SlotTime: "На какое время поставить напоминание?",
|
||||
dialogue.SlotText: "Так о чём напомнить?",
|
||||
dialogue.SlotKey: "Так что записать?",
|
||||
dialogue.SlotFn: "Так какое действие выполнить?",
|
||||
}
|
||||
|
||||
// clarifyResumedFor gives the resumed wording for a slot. ("", false) when the
|
||||
// slot has none, and the caller then resumes nothing rather than inventing a
|
||||
// question — a flow it cannot re-ask is one it must not claim to be holding.
|
||||
func clarifyResumedFor(slot dialogue.Slot) (string, bool) {
|
||||
q, ok := clarifyResumedVariants[slot]
|
||||
return q, ok
|
||||
}
|
||||
|
||||
// actNotRecognized is what an act she cannot run gets (Vikunja #556).
|
||||
//
|
||||
// The deck used to ask "Что сделать?" instead. That question has no answer he
|
||||
|
||||
@@ -452,13 +452,16 @@ func dialogueTraces() []trace {
|
||||
// still standing, on the same attempt — a side query is not a failed
|
||||
// answer and must not spend a retry.
|
||||
//
|
||||
// Unskipping this needs more than V-561. "на 9" and "на завтра" are not
|
||||
// read by StubDateTimeParser, which is what the offline floor runs, so
|
||||
// the row below it is the same shape in words the floor can parse and is
|
||||
// the one to watch first.
|
||||
// Unskipping this needs more than V-561, and V-561 landing did not change
|
||||
// that. The suspend and resume it asked for is done — the row below is
|
||||
// the same shape in words the floor can parse and is green. What is left
|
||||
// here is the parser: StubDateTimeParser does not read "на 9" or "на
|
||||
// завтра", so turn 3 lands as an answer that filled nothing and spends a
|
||||
// retry, which is what this row now fails on. V-562 and V-543 own the
|
||||
// ambiguous hour and the day correction behind those two words.
|
||||
{
|
||||
name: "the owner's transcript from V-561",
|
||||
skip: "V-561: a parked question is not suspended for a side query and never resumes",
|
||||
skip: "V-543/V-562: the floor's date parser reads neither «на 9» nor «на завтра»",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
@@ -470,13 +473,16 @@ func dialogueTraces() []trace {
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}},
|
||||
},
|
||||
// The same shape said in words StubDateTimeParser reads, so this row
|
||||
// turns green on V-561 alone. Same three claims: Rome is answered, the
|
||||
// question survives the side query on the same attempt, and the answer
|
||||
// after it completes the reminder he actually asked for.
|
||||
// The same shape said in words StubDateTimeParser reads. GREEN since
|
||||
// V-561. Same three claims: Rome is answered, the question survives the
|
||||
// side query on the same attempt, and the answer after it completes the
|
||||
// reminder he actually asked for.
|
||||
//
|
||||
// It sits under the "fail today" header because the row above it still
|
||||
// does. Do not re-skip it to tidy that up: this is the owner's
|
||||
// acceptance test in the only words the offline floor can read.
|
||||
{
|
||||
name: "nested question: a parked question, then one of his own",
|
||||
skip: "V-561: a side query drops the parked question instead of suspending it",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
|
||||
+152
-185
@@ -68,6 +68,11 @@ import (
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// stepUpTTL is how long one passkey assertion keeps the session stepped up.
|
||||
// Long enough for the unlock call that follows it, short enough that a walked
|
||||
// away laptop does not stay authorized.
|
||||
const stepUpTTL = 5 * time.Minute
|
||||
|
||||
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
||||
|
||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
|
||||
@@ -249,42 +254,12 @@ func run(args []string) error {
|
||||
|
||||
if !locked {
|
||||
rules = wireRules(cfg)
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
}
|
||||
gatherer = wireGatherer(st, cfg, rules)
|
||||
|
||||
// phraser
|
||||
phr = phraser.NewStub()
|
||||
if cfg.Phraser != nil {
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
CacheRAMMiB: cacheRAMMiB(cfg.Phraser.CacheRAMMiB),
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
pc.BinPath = "llama-server"
|
||||
}
|
||||
if pc.Listen == "" {
|
||||
pc.Listen = "127.0.0.1:0"
|
||||
}
|
||||
if pc.NCtx <= 0 {
|
||||
pc.NCtx = 2048
|
||||
}
|
||||
if pc.Timeout <= 0 {
|
||||
pc.Timeout = 30 * time.Second
|
||||
}
|
||||
var err error
|
||||
phr, err = phraser.NewLLMPhraser(ctx, pc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
phr, err = wirePhraser(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
|
||||
// ecosystem — nexus + hexis + praxis (all over HTTP; no direct DB access)
|
||||
@@ -297,48 +272,13 @@ func run(args []string) error {
|
||||
}
|
||||
|
||||
// delivery
|
||||
var ntfy delivery.Sink
|
||||
if cfg.Ntfy != nil {
|
||||
s, err := ntfysink.New(*cfg.Ntfy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
ntfy = s
|
||||
dispatcher, err = wireDispatcher(st, cfg, voiceW)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire telegram sink: %w", err)
|
||||
}
|
||||
telegram = s
|
||||
}
|
||||
var voiceSink delivery.Sink
|
||||
if voiceW != nil {
|
||||
voiceSink = voiceW.voiceSink
|
||||
}
|
||||
// A crashed prior run may have left "pending" delivery attempts (send
|
||||
// may have landed externally, then the process died before recording
|
||||
// it) — reconcile them to "unknown" before the tick loop resumes
|
||||
// sending, so nothing auto-resends into that ambiguity.
|
||||
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("delivery outbox reconcile: %v", err)
|
||||
}
|
||||
dispatcher = delivery.NewDispatcher(delivery.Config{
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
Outbox: st,
|
||||
})
|
||||
|
||||
// tick loop
|
||||
tickInterval := time.Duration(cfg.TickInterval)
|
||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
tl = wireTickLoop(st, gatherer, dispatcher, phr, rules, cfg)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||
@@ -380,7 +320,7 @@ func run(args []string) error {
|
||||
return fmt.Errorf("ipc listen: %w", err)
|
||||
}
|
||||
|
||||
passkeySess := webauthn.NewPasskeySession(5 * time.Minute)
|
||||
passkeySess := webauthn.NewPasskeySession(stepUpTTL)
|
||||
|
||||
// Set Server.Check — the single authorization guard, run once by
|
||||
// Server.dispatch before any CoreAPI method is called (see
|
||||
@@ -528,40 +468,11 @@ func run(args []string) error {
|
||||
|
||||
// Wire everything.
|
||||
rules = wireRules(cfg)
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
}
|
||||
gatherer = wireGatherer(st, cfg, rules)
|
||||
|
||||
phr = phraser.NewStub()
|
||||
if cfg.Phraser != nil {
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
CacheRAMMiB: cacheRAMMiB(cfg.Phraser.CacheRAMMiB),
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
pc.BinPath = "llama-server"
|
||||
}
|
||||
if pc.Listen == "" {
|
||||
pc.Listen = "127.0.0.1:0"
|
||||
}
|
||||
if pc.NCtx <= 0 {
|
||||
pc.NCtx = 2048
|
||||
}
|
||||
if pc.Timeout <= 0 {
|
||||
pc.Timeout = 30 * time.Second
|
||||
}
|
||||
phr, err = phraser.NewLLMPhraser(ctx, pc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
phr, err = wirePhraser(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
|
||||
eco = wireEcosystem(cfg)
|
||||
@@ -571,43 +482,12 @@ func run(args []string) error {
|
||||
return fmt.Errorf("wire voice: %w", err)
|
||||
}
|
||||
|
||||
var ntfy delivery.Sink
|
||||
if cfg.Ntfy != nil {
|
||||
s, err := ntfysink.New(*cfg.Ntfy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
ntfy = s
|
||||
dispatcher, err = wireDispatcher(st, cfg, voiceW)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire telegram sink: %w", err)
|
||||
}
|
||||
telegram = s
|
||||
}
|
||||
var voiceSink delivery.Sink
|
||||
if voiceW != nil {
|
||||
voiceSink = voiceW.voiceSink
|
||||
}
|
||||
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("delivery outbox reconcile: %v", err)
|
||||
}
|
||||
dispatcher = delivery.NewDispatcher(delivery.Config{
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
Outbox: st,
|
||||
})
|
||||
|
||||
tickInterval := time.Duration(cfg.TickInterval)
|
||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
tl = wireTickLoop(st, gatherer, dispatcher, phr, rules, cfg)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||
@@ -698,71 +578,39 @@ func run(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
goWorker(&wg, func() {
|
||||
if err := srv.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("ipc serve: %v", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
log.Printf("mavend: ipc listening on %s", srv.Path())
|
||||
|
||||
if !locked && voiceW != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
goWorker(&wg, func() {
|
||||
if err := voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("voice serve: %v", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
log.Printf("mavend: voice listening on %s", voiceW.server.Addr())
|
||||
}
|
||||
|
||||
if !locked {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tl.run(ctx)
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
factWorker.run(ctx)
|
||||
}()
|
||||
goWorker(&wg, func() { tl.run(ctx) })
|
||||
goWorker(&wg, func() { factWorker.run(ctx) })
|
||||
if evalWorker != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
evalWorker.run(ctx)
|
||||
}()
|
||||
goWorker(&wg, func() { evalWorker.run(ctx) })
|
||||
}
|
||||
if feedWkr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
feedWkr.run(ctx)
|
||||
}()
|
||||
goWorker(&wg, func() { feedWkr.run(ctx) })
|
||||
}
|
||||
if crawlWkr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
crawlWkr.run(ctx)
|
||||
}()
|
||||
goWorker(&wg, func() { crawlWkr.run(ctx) })
|
||||
}
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voiceW.mcp.run(ctx)
|
||||
}()
|
||||
goWorker(&wg, func() { voiceW.mcp.run(ctx) })
|
||||
}
|
||||
if voiceW != nil && voiceW.home != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voiceW.home.run(ctx)
|
||||
}()
|
||||
goWorker(&wg, func() { voiceW.home.run(ctx) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,6 +698,125 @@ func waitWorkers(wg *sync.WaitGroup, d time.Duration) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Phraser defaults, applied when the config block leaves a field unset. They
|
||||
// are the daemon's, not the library's: phraser.Config carries no defaults of
|
||||
// its own, so an empty field here would reach llama-server as an empty flag.
|
||||
const (
|
||||
defaultLlamaBin = "llama-server"
|
||||
defaultPhraserListen = "127.0.0.1:0"
|
||||
defaultPhraserNCtx = 2048
|
||||
defaultPhraserTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// wirePhraser builds the phrasing seam. No phraser block means the
|
||||
// deterministic stub, which is the floor and not an error: the daemon answers
|
||||
// without a model, in fixed words.
|
||||
func wirePhraser(ctx context.Context, cfg *config.Config) (phraser.Phraser, error) {
|
||||
if cfg.Phraser == nil {
|
||||
return phraser.NewStub(), nil
|
||||
}
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
CacheRAMMiB: cacheRAMMiB(cfg.Phraser.CacheRAMMiB),
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
pc.BinPath = defaultLlamaBin
|
||||
}
|
||||
if pc.Listen == "" {
|
||||
pc.Listen = defaultPhraserListen
|
||||
}
|
||||
if pc.NCtx <= 0 {
|
||||
pc.NCtx = defaultPhraserNCtx
|
||||
}
|
||||
if pc.Timeout <= 0 {
|
||||
pc.Timeout = defaultPhraserTimeout
|
||||
}
|
||||
return phraser.NewLLMPhraser(ctx, pc)
|
||||
}
|
||||
|
||||
// wireGatherer builds the nudge gatherer over the given rule set and applies
|
||||
// the configured quiet hours.
|
||||
func wireGatherer(st *store.Store, cfg *config.Config, rules []loop.Rule) *loop.Gatherer {
|
||||
g := loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
g.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// wireDispatcher builds the delivery fan-out. Each sink stays nil unless its
|
||||
// config block is present, and a sink that fails to build fails the boot
|
||||
// rather than going quiet.
|
||||
//
|
||||
// A crashed prior run may have left "pending" delivery attempts (send may have
|
||||
// landed externally, then the process died before recording it). They are
|
||||
// reconciled to "unknown" here, before the tick loop resumes sending, so
|
||||
// nothing auto-resends into that ambiguity.
|
||||
func wireDispatcher(st *store.Store, cfg *config.Config, voiceW *voiceWiring) (*delivery.Dispatcher, error) {
|
||||
var ntfy delivery.Sink
|
||||
if cfg.Ntfy != nil {
|
||||
s, err := ntfysink.New(*cfg.Ntfy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
ntfy = s
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire telegram sink: %w", err)
|
||||
}
|
||||
telegram = s
|
||||
}
|
||||
var voiceSink delivery.Sink
|
||||
if voiceW != nil {
|
||||
voiceSink = voiceW.voiceSink
|
||||
}
|
||||
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("delivery outbox reconcile: %v", err)
|
||||
}
|
||||
return delivery.NewDispatcher(delivery.Config{
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
Outbox: st,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// wireTickLoop reads the loop's three intervals and its schedules out of the
|
||||
// config, so the two boot paths cannot disagree about them.
|
||||
func wireTickLoop(st *store.Store, gatherer *loop.Gatherer, dispatcher *delivery.Dispatcher, phr phraser.Phraser, rules []loop.Rule, cfg *config.Config) *tickLoop {
|
||||
return newTickLoop(st, gatherer, dispatcher, phr, rules,
|
||||
time.Duration(cfg.TickInterval),
|
||||
time.Duration(cfg.RepeatInterval),
|
||||
time.Duration(cfg.AutotuneInterval),
|
||||
cfg.Digest,
|
||||
routinesFromConfig(cfg.Routines),
|
||||
config.MorningRoutinesFromConfig(cfg.MorningRoutines),
|
||||
cfg.PatternProposals)
|
||||
}
|
||||
|
||||
// goWorker starts run on its own goroutine and registers it with wg, so
|
||||
// shutdown can wait for it inside workerGrace.
|
||||
func goWorker(wg *sync.WaitGroup, run func()) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
run()
|
||||
}()
|
||||
}
|
||||
|
||||
// wireRules builds the nudge rule set, minus anything config turned off. The
|
||||
// drop is logged because a rule vanishing silently is indistinguishable from a
|
||||
// rule that is broken, and the next person to wonder why she stopped nudging
|
||||
|
||||
@@ -167,8 +167,12 @@ func TestTurnRoleNamesACorrection(t *testing.T) {
|
||||
// TestRomeIsAnsweredAndTheReminderIsNotInvented — the measured failure of
|
||||
// 2026-08-05, end to end through the real cascade. "напомни позвонить маме"
|
||||
// parks the time question; the weather question that follows must not become
|
||||
// its answer, must not create a reminder for a time nobody asked for, and must
|
||||
// not be dropped in silence.
|
||||
// its answer and must not create a reminder for a time nobody asked for.
|
||||
//
|
||||
// V-560 got that far by DROPPING the parked request and saying so, and the
|
||||
// owner rejected the notice on sight: he did not ask to lose the reminder. So
|
||||
// the contract here is V-561's — the flow is suspended, this turn's reply ends
|
||||
// with the question coming back, and nothing says anything was let go.
|
||||
func TestRomeIsAnsweredAndTheReminderIsNotInvented(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newRoutingClarifyHandler(t)
|
||||
@@ -180,14 +184,27 @@ func TestRomeIsAnsweredAndTheReminderIsNotInvented(t *testing.T) {
|
||||
if strings.Contains(reply, "напомню") {
|
||||
t.Fatalf("the question was eaten as the reminder's time again: %q", reply)
|
||||
}
|
||||
if !strings.HasPrefix(reply, clarifyDropped) {
|
||||
t.Fatalf("the parked request died without a word: %q", reply)
|
||||
if strings.Contains(reply, clarifyDropped) {
|
||||
t.Fatalf("a side query suspends the flow; nothing was dropped, so nothing may say so: %q", reply)
|
||||
}
|
||||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||||
if !strings.HasSuffix(reply, resumed) {
|
||||
t.Fatalf("the reply must end with the resumed question %q, got %q", resumed, reply)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||||
t.Fatalf("a reminder was invented for a time nobody asked for: %v err=%v", reminders, err)
|
||||
}
|
||||
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) != nil {
|
||||
t.Fatal("the parked question must be gone, not left to eat the next turn")
|
||||
// Still parked, and still on its first attempt: he answered the side query,
|
||||
// not this question, so no retry may have been spent on it.
|
||||
q := h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now())
|
||||
if q == nil {
|
||||
t.Fatal("the parked question was dropped instead of suspended")
|
||||
}
|
||||
if q.Attempts != 1 {
|
||||
t.Fatalf("the side query spent a clarify attempt: attempts = %d, want 1", q.Attempts)
|
||||
}
|
||||
if !strings.Contains(q.Utterance, "маме") {
|
||||
t.Fatalf("the suspended request lost what it was about: %q", q.Utterance)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,17 @@ type turnRoute struct {
|
||||
// dropped — what she let go of this turn and must say out loud. A parked
|
||||
// request that dies without a word leaves him thinking it landed.
|
||||
dropped string
|
||||
|
||||
// resume — the parked question, re-worded, to put AFTER this turn's answer
|
||||
// (Vikunja #561). A side query does not end the flow it interrupted, so the
|
||||
// reply carries two acts: the answer he asked for, then the question he
|
||||
// still owes her. Empty ⇒ nothing was suspended.
|
||||
resume string
|
||||
// suspended — a flow is parked underneath this turn. askClarify reads it to
|
||||
// decide between Put (replace the top) and Push (keep the flow and stack the
|
||||
// new question on it), because a side query that needs clarifying of its own
|
||||
// must not overwrite the thing it interrupted.
|
||||
suspended bool
|
||||
}
|
||||
|
||||
type turnRouteKey struct{}
|
||||
|
||||
+9
-1
@@ -255,7 +255,7 @@ const (
|
||||
// path wraps it in stt/tts, the text path returns it as-is.
|
||||
//
|
||||
// The ordering is load-bearing — see the step comments.
|
||||
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) string {
|
||||
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) (reply string) {
|
||||
// 0. the decision record (V-564). Installed here rather than in the IPC
|
||||
// entry point, so the mic, telegram and the web all leave the same trail —
|
||||
// a record only the web produced would be missing exactly the turns that
|
||||
@@ -311,6 +311,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
// with — carried on the same notice, so every exit below keeps it.
|
||||
expiredNotice = withNotice(expiredNotice, rt.dropped)
|
||||
|
||||
// 3b. and if it SUSPENDED a request instead of letting it go, the question
|
||||
// comes back on the end of whatever these words are answered with (Vikunja
|
||||
// #561). A deferred append rather than a call at each exit: there are eight
|
||||
// returns between here and the replier, and the flow has to survive all of
|
||||
// them — one that forgot would be a request parked for ever, waiting for an
|
||||
// answer to a question he never heard asked.
|
||||
defer func() { reply = withResumed(reply, rt.resume) }()
|
||||
|
||||
// 4. quiet-hours toggle — keyword match, not classifier-dependent.
|
||||
// "тихий режим" / "quiet on" would route through the classifier
|
||||
// unreliably (it's a command, not a free-form query), so we match it
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
//go:embed chat.html
|
||||
var chatPageHTML string
|
||||
|
||||
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
|
||||
// and the handler redirects back to /chat with the response.
|
||||
var chatTmpl = parsePage("chat", chatPageHTML, nil)
|
||||
|
||||
// chatMsg — one message in the conversation history.
|
||||
type chatMsg struct {
|
||||
Role string // "user" | "assistant"
|
||||
Text string
|
||||
// Source — the query source that claimed the turn, shown as a badge beside
|
||||
// the reply. Empty for a turn no source claimed (V-539).
|
||||
Source string
|
||||
}
|
||||
|
||||
// handleChatPage renders the chat conversation page.
|
||||
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "chat") {
|
||||
return
|
||||
}
|
||||
msgs := []chatMsg{}
|
||||
// Read user message + reply from query params (set by /api/chat redirect).
|
||||
if q := r.URL.Query().Get("q"); q != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "user", Text: q})
|
||||
}
|
||||
if reply := r.URL.Query().Get("r"); reply != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")})
|
||||
}
|
||||
renderPage(w, chatTmpl, struct {
|
||||
Error string
|
||||
Messages []chatMsg
|
||||
}{Messages: msgs})
|
||||
}
|
||||
|
||||
// handleChatAPI processes a chat message POST and redirects back to /chat.
|
||||
//
|
||||
// State-changing, and the widest surface on this server: the text reaches the
|
||||
// router, the LLM, and through mavend's applyAction the whole action path
|
||||
// including `act` — so it is gated on the same step-up as POST /tools and
|
||||
// POST /api/revert (Vikunja #317). With WebAuthn unconfigured the gate is
|
||||
// fail-open exactly like the others (see stepUpOK); with -require-stepup it
|
||||
// denies, which is the point of that flag.
|
||||
func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "chat") {
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// One conversation id for the whole web chat, and a different one from
|
||||
// telegram or the mic. A parked question belongs to the reach that was
|
||||
// asked; before this, a clarify nobody answered on the web ate the next
|
||||
// utterance spoken at the mic (Vikunja #466). This server has no
|
||||
// per-browser session, so every browser tab is the same conversation —
|
||||
// which is right for a single-owner box.
|
||||
reply, err := core.Chat(r.Context(), "web", text)
|
||||
if err != nil {
|
||||
log.Printf("chat api: %v", err)
|
||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// The claiming query source rides back on the redirect so the page can show
|
||||
// it. Empty for a turn no source claimed, which is most of them.
|
||||
dest := "/chat?q=" + url.QueryEscape(text) + "&r=" + url.QueryEscape(reply.Reply)
|
||||
if reply.Source != "" {
|
||||
dest += "&s=" + url.QueryEscape(reply.Source)
|
||||
}
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
}
|
||||
@@ -117,8 +117,5 @@ func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs, core
|
||||
d.Calls.Rows = rows
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := ecosystemTmpl.Execute(w, d); err != nil {
|
||||
log.Printf("ecosystem render: %v", err)
|
||||
}
|
||||
renderPage(w, ecosystemTmpl, d)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// The two fact-writing API routes: POST /api/signal appends a presence
|
||||
// observation, POST /api/revert voids the latest fact for a key. Neither
|
||||
// renders a page.
|
||||
|
||||
// presenceSignals — the only fact keys /api/signal may write. mavweb is a
|
||||
// network-facing surface inside wg; an allowlist keeps a compromised caller
|
||||
// boxed to forging weak presence signals (reachability, multi-source, never
|
||||
// truth) — it can't write arbitrary facts. ponytail: floor auth (wg-only); a
|
||||
// per-signal token belongs here if the tunnel ever hosts untrusted devices.
|
||||
var presenceSignals = map[string]string{
|
||||
"desk_active": "infer:hyprland",
|
||||
"page_heartbeat": "infer:heartbeat",
|
||||
"wg_handshake": "infer:wg",
|
||||
}
|
||||
|
||||
// handleSignal ingests one presence signal and writes a fresh fact through
|
||||
// CoreAPI. The fact's timestamp (now) is all the presence scorer reads; value
|
||||
// is a marker. Only allowlisted keys are accepted (see presenceSignals).
|
||||
func handleSignal(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "presence ingest") {
|
||||
return
|
||||
}
|
||||
key := r.URL.Query().Get("key")
|
||||
source, ok := presenceSignals[key]
|
||||
if !ok {
|
||||
http.Error(w, "unknown signal key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// kind=env: an observation about the device/surface, NOT a self-fact — a
|
||||
// passive signal never writes truth about you (spec), it only feeds
|
||||
// presence. confidence 1.0: the reading ("input happened") is certain;
|
||||
// presence applies its own per-signal weight/decay on top.
|
||||
if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{
|
||||
Ts: time.Now(),
|
||||
Kind: "env",
|
||||
Key: key,
|
||||
Value: `"active"`,
|
||||
Source: source,
|
||||
Confidence: 1.0,
|
||||
}); err != nil {
|
||||
log.Printf("signal %s: %v", key, err)
|
||||
http.Error(w, "write failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "revert") {
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(r.FormValue("key"))
|
||||
if key == "" {
|
||||
http.Error(w, "key required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
newID, err := core.RevertFact(r.Context(), key)
|
||||
if err != nil {
|
||||
log.Printf("revert %q: %v", key, err)
|
||||
if errors.Is(err, ipc.ErrNoFact) {
|
||||
http.Error(w, "no fact to revert", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "revert failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
log.Printf("reverted fact for key=%s, new_id=%d", key, newID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"reverted": true, "new_id": newID})
|
||||
}
|
||||
+71
-1691
File diff suppressed because it is too large
Load Diff
+5
-41
@@ -2,8 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -31,43 +31,10 @@ type modelController interface {
|
||||
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
|
||||
}
|
||||
|
||||
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellHTML + modelsHTML))
|
||||
//go:embed models.html
|
||||
var modelsHTML string
|
||||
|
||||
const modelsHTML = `{{template "shellTop" "models"}}
|
||||
<h1>Resident model</h1>
|
||||
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
|
||||
<p class=hint>a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one <code>mavupdate</code> does — comes back on <code>phraser.model_path</code> from the config. Make it stick by editing that.</p>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
||||
{{if .Off}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>swap not configured</h2>
|
||||
<p class=hint>this core has no <code>phraser.swap_models</code> allowlist, so there is nothing to swap to. Add the gguf paths you allow to <code>deploy/mavend.json</code> and restart once.</p>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>loaded now</h2>
|
||||
<div class=scroll><table>
|
||||
<tr><th>model</th><td><code>{{.Status.Model}}</code></td></tr>
|
||||
<tr><th>file</th><td><code>{{.Status.ModelPath}}</code></td></tr>
|
||||
<tr><th>server</th><td><code>{{.Status.BaseURL}}</code></td></tr>
|
||||
<tr><th>n_ctx</th><td>{{.Status.NCtx}}</td></tr>
|
||||
<tr><th>n_gpu_layers</th><td>{{.Status.NGpuLayers}}</td></tr>
|
||||
</table></div>
|
||||
<p class=hint>the model name is what llama-server reports for itself, not what the config says it should be.</p>
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>allowed models <span class=badge>{{len .Status.Swappable}}</span></h2>
|
||||
{{if .Status.Swappable}}<div class=scroll><table><tr><th>file</th><th></th></tr>
|
||||
{{range .Status.Swappable}}<tr><td><code>{{.}}</code></td>
|
||||
<td><form method=post action=/models class=inline-form>
|
||||
<input type=hidden name=model_path value="{{.}}">
|
||||
<button class=btn>load this one</button></form></td></tr>{{end}}
|
||||
</table></div>
|
||||
{{else}}<div class=empty><div>no models allowlisted</div></div>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{template "shellBottom"}}`
|
||||
var modelsTmpl = parsePage("models", modelsHTML, nil)
|
||||
|
||||
type modelsPage struct {
|
||||
Msg string
|
||||
@@ -154,8 +121,5 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swap
|
||||
}
|
||||
}
|
||||
page.Status = st
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := modelsTmpl.Execute(w, page); err != nil {
|
||||
log.Printf("models render: %v", err)
|
||||
}
|
||||
renderPage(w, modelsTmpl, page)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{{template "shellTop" "models"}}
|
||||
<h1>Resident model</h1>
|
||||
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
|
||||
<p class=hint>a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one <code>mavupdate</code> does — comes back on <code>phraser.model_path</code> from the config. Make it stick by editing that.</p>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
||||
{{if .Off}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>swap not configured</h2>
|
||||
<p class=hint>this core has no <code>phraser.swap_models</code> allowlist, so there is nothing to swap to. Add the gguf paths you allow to <code>deploy/mavend.json</code> and restart once.</p>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>loaded now</h2>
|
||||
<div class=scroll><table>
|
||||
<tr><th>model</th><td><code>{{.Status.Model}}</code></td></tr>
|
||||
<tr><th>file</th><td><code>{{.Status.ModelPath}}</code></td></tr>
|
||||
<tr><th>server</th><td><code>{{.Status.BaseURL}}</code></td></tr>
|
||||
<tr><th>n_ctx</th><td>{{.Status.NCtx}}</td></tr>
|
||||
<tr><th>n_gpu_layers</th><td>{{.Status.NGpuLayers}}</td></tr>
|
||||
</table></div>
|
||||
<p class=hint>the model name is what llama-server reports for itself, not what the config says it should be.</p>
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>allowed models <span class=badge>{{len .Status.Swappable}}</span></h2>
|
||||
{{if .Status.Swappable}}<div class=scroll><table><tr><th>file</th><th></th></tr>
|
||||
{{range .Status.Swappable}}<tr><td><code>{{.}}</code></td>
|
||||
<td><form method=post action=/models class=inline-form>
|
||||
<input type=hidden name=model_path value="{{.}}">
|
||||
<button class=btn>load this one</button></form></td></tr>{{end}}
|
||||
</table></div>
|
||||
{{else}}<div class=empty><div>no models allowlisted</div></div>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{template "shellBottom"}}
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
//go:embed notifications.html
|
||||
var notificationsHTML string
|
||||
|
||||
var notificationsTmpl = parsePage("notifications", notificationsHTML, nil)
|
||||
|
||||
// deliveryRow is one outbox line, with every timestamp already formatted so
|
||||
// the template holds no date logic — same shape as taskRow.
|
||||
type deliveryRow struct {
|
||||
Kind string
|
||||
Target string
|
||||
Channel string
|
||||
Status string
|
||||
Created string
|
||||
Completed string
|
||||
}
|
||||
|
||||
func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
|
||||
out := make([]deliveryRow, 0, len(as))
|
||||
for _, a := range as {
|
||||
target := a.Rule
|
||||
if target == "" && a.ReminderID != 0 {
|
||||
target = "reminder #" + strconv.FormatInt(a.ReminderID, 10)
|
||||
}
|
||||
row := deliveryRow{
|
||||
Kind: a.Kind,
|
||||
Target: target,
|
||||
Channel: a.Channel,
|
||||
Status: a.Status,
|
||||
Created: a.Created.Format("02.01 15:04"),
|
||||
}
|
||||
if a.Completed != nil {
|
||||
row.Completed = a.Completed.Format("15:04")
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "notifications") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
nudges, err := core.RecentNudges(ctx, 50)
|
||||
if err != nil {
|
||||
log.Printf("notifications: %v", err)
|
||||
http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// The outbox, on the page that already answers "what did she send".
|
||||
// A failed or dropped attempt is why she went quiet, and until now it was
|
||||
// recorded and unreadable (Vikunja #390). Filter with ?status=dropped.
|
||||
status := r.URL.Query().Get("status")
|
||||
attempts, err := core.DeliveryAttempts(ctx, status, 50)
|
||||
if err != nil {
|
||||
// The nudge list is still worth showing, so this is a note on the page
|
||||
// rather than a dead page.
|
||||
log.Printf("notifications: delivery attempts: %v", err)
|
||||
}
|
||||
renderPage(w, notificationsTmpl, map[string]any{
|
||||
"Nudges": nudges,
|
||||
"Attempts": deliveryRows(attempts),
|
||||
"Status": status,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// The read-only pages: dash, history, trace, morning, events, and the voice
|
||||
// page mounted at "/". Each is GET-only, reads through CoreAPI and renders.
|
||||
// The write surfaces live next to their own handlers (tasks.go, tools.go,
|
||||
// routines.go, chat.go).
|
||||
|
||||
//go:embed dash.html
|
||||
var dashHTML string
|
||||
|
||||
//go:embed history.html
|
||||
var historyHTML string
|
||||
|
||||
//go:embed trace.html
|
||||
var traceHTML string
|
||||
|
||||
//go:embed morning.html
|
||||
var morningHTML string
|
||||
|
||||
//go:embed events.html
|
||||
var eventsHTML string
|
||||
|
||||
//go:embed voice.html
|
||||
var voiceHTML string
|
||||
|
||||
//go:embed ecosystem.html
|
||||
var ecosystemHTML string
|
||||
|
||||
// dashTmpl — the monitoring read surface, server-rendered from dash.html;
|
||||
// a small fetch loop refreshes the tables in place. html/template escapes the
|
||||
// user text in facts/nudges. Read-only: browses the append-only store via
|
||||
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
|
||||
var dashTmpl = parsePage("dash", dashHTML, nil)
|
||||
|
||||
var historyTmpl = parsePage("history", historyHTML, nil)
|
||||
|
||||
var traceTmpl = parsePage("trace", traceHTML, template.FuncMap{
|
||||
"fmtTime": func(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.Format("15:04:05")
|
||||
},
|
||||
"join": strings.Join,
|
||||
})
|
||||
|
||||
// morningTmpl — read-only view of today's checklist state per configured
|
||||
// morning routine (internal/morning). Same shape as trace.html: a plain
|
||||
// server-rendered page, refreshed on reload — no live-update loop, since
|
||||
// checklist state changes on the scale of minutes, not seconds.
|
||||
var morningTmpl = parsePage("morning", morningHTML, nil)
|
||||
|
||||
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
||||
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
||||
var eventsTmpl = parsePage("events", eventsHTML, nil)
|
||||
|
||||
var voiceTmpl = parsePage("voice", voiceHTML, nil)
|
||||
|
||||
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
|
||||
// human surface is here (they ship no web UI of their own).
|
||||
var ecosystemTmpl = parsePage("ecosystem", ecosystemHTML, nil)
|
||||
|
||||
func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "dash") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
pres, err1 := core.Presence(ctx)
|
||||
facts, err2 := core.RecentFacts(ctx, 50)
|
||||
nudges, err3 := core.RecentNudges(ctx, 50)
|
||||
notes, err4 := core.RecentNotes(ctx, 50)
|
||||
if err := cmp.Or(err1, err2, err3, err4); err != nil {
|
||||
log.Printf("dash: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
renderPage(w, dashTmpl, struct {
|
||||
Presence ipc.Presence
|
||||
Facts []ipc.Fact
|
||||
Nudges []ipc.Nudge
|
||||
Notes []ipc.Note
|
||||
}{pres, facts, nudges, notes})
|
||||
}
|
||||
|
||||
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "history") {
|
||||
return
|
||||
}
|
||||
facts, err := core.RecentFacts(r.Context(), 200)
|
||||
if err != nil {
|
||||
log.Printf("history: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
renderPage(w, historyTmpl, struct {
|
||||
Facts []ipc.Fact
|
||||
}{facts})
|
||||
}
|
||||
|
||||
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "trace") {
|
||||
return
|
||||
}
|
||||
trace, err := core.TickTrace(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("trace: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// The turn records share this page rather than getting one of their own
|
||||
// (V-564). Both answer the same question, who won and who lost and why, and
|
||||
// one is about nudges while the other is about utterances. A read failure
|
||||
// here is not fatal to the page. The rule trace above it still renders, and
|
||||
// a daemon too old to know the method is the ordinary case during a rolling
|
||||
// deploy.
|
||||
turns, err := core.TurnDecisions(r.Context(), 25)
|
||||
if err != nil {
|
||||
log.Printf("trace: turn decisions: %v", err)
|
||||
}
|
||||
renderPage(w, traceTmpl, traceData{Tick: trace, Turns: turns})
|
||||
}
|
||||
|
||||
// traceData — what trace.html renders: the last tick's rule arbitration and the
|
||||
// last turns' claim arbitration.
|
||||
type traceData struct {
|
||||
Tick ipc.TickTrace
|
||||
Turns []ipc.TurnDecision
|
||||
}
|
||||
|
||||
// morningView — what /morning renders: today's plan on top, the checklist
|
||||
// state under it. PlanErr is set instead of Plan when the core could not build
|
||||
// a plan, so the page says so rather than showing an empty day.
|
||||
type morningView struct {
|
||||
Plan *ipc.DayPlan
|
||||
PlanErr string
|
||||
Routines []ipc.MorningRoutineStatus
|
||||
}
|
||||
|
||||
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "morning") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
status, err := core.MorningStatus(ctx)
|
||||
if err != nil {
|
||||
log.Printf("morning: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
view := morningView{Routines: status}
|
||||
// The day plan (#128) shows on this page because it is the same question at
|
||||
// a different scale. A plan read that fails must not take the checklist
|
||||
// down with it — the page degrades to what it had before.
|
||||
plan, err := core.DayPlan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("morning: day plan: %v", err)
|
||||
view.PlanErr = err.Error()
|
||||
} else {
|
||||
view.Plan = &plan
|
||||
}
|
||||
renderPage(w, morningTmpl, view)
|
||||
}
|
||||
|
||||
// eventsView — what /events renders. Err is set instead of Events when the
|
||||
// core could not serve the journal, so the page says why rather than showing an
|
||||
// empty intake and implying nothing arrived.
|
||||
type eventsView struct {
|
||||
Events []ipc.IntakeEvent
|
||||
Err string
|
||||
}
|
||||
|
||||
// eventsPageLimit — how many envelopes the page shows. The ring holds more; a
|
||||
// page is for scanning what just happened, not for archaeology.
|
||||
const eventsPageLimit = 200
|
||||
|
||||
func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "intake journal") {
|
||||
return
|
||||
}
|
||||
var view eventsView
|
||||
evs, err := core.RecentEvents(r.Context(), eventsPageLimit)
|
||||
if err != nil {
|
||||
log.Printf("events: %v", err)
|
||||
view.Err = err.Error()
|
||||
} else {
|
||||
view.Events = evs
|
||||
}
|
||||
renderPage(w, eventsTmpl, view)
|
||||
}
|
||||
|
||||
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, voiceTmpl, nil)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{{template "shellTop" "passkey"}}
|
||||
<h1>Passkey</h1>
|
||||
<p class=hint>Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.</p>
|
||||
<div class=flex gap-2>
|
||||
<button class=btn onclick=enroll()>enroll passkey</button>
|
||||
<button class=btn onclick=assert()>assert (step-up)</button>
|
||||
<button class=btn onclick=rewrapKey()>rewrite cold-start key</button>
|
||||
<a href=/tools><button class=btn-primary>→ tools</button></a>
|
||||
</div>
|
||||
<p class=hint>Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.</p>
|
||||
<div id=msg></div>
|
||||
{{template "shellBottom"}}
|
||||
<script>
|
||||
const b64u=b=>btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||
const ub64=s=>{s=s.replace(/-/g,'+').replace(/_/g,'/');const b=atob(s),a=new Uint8Array(b.length);for(let i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return a;};
|
||||
const say=(t,ok)=>{const m=document.getElementById('msg');m.textContent=t;m.className=ok?'msg msg-ok':'msg msg-err';};
|
||||
async function enroll(){try{
|
||||
const {challenge,options}=await (await fetch('/auth/webauthn/register/begin')).json();
|
||||
options.challenge=ub64(options.challenge);
|
||||
options.user.id=ub64(options.user.id);
|
||||
const c=await navigator.credentials.create({publicKey:options});
|
||||
const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
||||
clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})});
|
||||
if(!r.ok){say('enroll failed: '+await r.text(),false);return;}
|
||||
// The wrapped key can only be written from an assertion: PRF results are
|
||||
// not produced at create() time on most authenticators. Enrolment reports
|
||||
// whether PRF is available at all so he is not told cold-start works when
|
||||
// it cannot.
|
||||
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||
const prfOK=!!(ext.prf&&ext.prf.enabled);
|
||||
say(prfOK?'enrolled ✓ — now assert once to write the cold-start key':
|
||||
'enrolled ✓ — but this authenticator has no PRF: cold-start unlock unavailable',true);
|
||||
}catch(e){say('enroll error: '+e,false);}}
|
||||
async function assert(explicit){try{
|
||||
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
|
||||
options.challenge=ub64(options.challenge);
|
||||
const c=await navigator.credentials.get({publicKey:options});
|
||||
// The PRF result is the cold-start secret. It never touches localStorage
|
||||
// and is posted once, over the same request as the assertion.
|
||||
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||
const prf=ext.prf&&ext.prf.results&&ext.prf.results.first?b64u(ext.prf.results.first):'';
|
||||
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify({challenge,prf,explicit:!!explicit,credential:{id:c.id,type:c.type,response:{
|
||||
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
|
||||
signature:b64u(c.response.signature)}}})});
|
||||
if(!r.ok){say('assert failed: '+await r.text(),false);return;}
|
||||
if(!prf){say('stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true);return;}
|
||||
say(explicit?'stepped up ✓ — cold-start key now points at this passkey':
|
||||
'stepped up ✓ — enable tools now',true);
|
||||
}catch(e){say('assert error: '+e,false);}}
|
||||
// Rewriting the wrapped key is a separate gesture, never a side effect of a
|
||||
// step-up. Only this button sets explicit, and only explicit lets the daemon
|
||||
// replace a blob that already exists.
|
||||
async function rewrapKey(){
|
||||
if(!confirm('Rewrite the cold-start key under the passkey you are about to assert? Every other enrolled passkey stops being able to unlock a cold-booted daemon.'))return;
|
||||
await assert(true);}
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
//go:embed reminders.html
|
||||
var remindersHTML string
|
||||
|
||||
var remindersTmpl = parsePage("reminders", remindersHTML, nil)
|
||||
|
||||
// reminderRow is one line on /reminders, with the payload unwrapped and both
|
||||
// timestamps already in his clock.
|
||||
//
|
||||
// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read
|
||||
// `{"text":"выпить таблетки"}` and fired an hour off what he was told
|
||||
// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal
|
||||
// shape he never chose, and a time on a page he reads is the time on his wall.
|
||||
type reminderRow struct {
|
||||
Created string
|
||||
Fires string
|
||||
Status string
|
||||
Text string
|
||||
}
|
||||
|
||||
// reminderText unwraps the {"text":...} payload the router writes.
|
||||
//
|
||||
// A copy of store.ReminderText rather than a call to it, because mavweb is one
|
||||
// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The
|
||||
// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to
|
||||
// whoever renders it. Payload that is not that shape is shown as he said it.
|
||||
func reminderText(payload string) string {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &m); err == nil {
|
||||
if t, ok := m["text"]; ok {
|
||||
if s, isStr := t.(string); isStr && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(payload)
|
||||
}
|
||||
|
||||
func reminderRows(rs []ipc.Reminder) []reminderRow {
|
||||
out := make([]reminderRow, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
out = append(out, reminderRow{
|
||||
Created: r.CreatedTs.Local().Format("02 Jan 15:04"),
|
||||
Fires: r.FireTs.Local().Format("02 Jan 15:04"),
|
||||
Status: r.Status,
|
||||
Text: reminderText(r.Payload),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "reminders") {
|
||||
return
|
||||
}
|
||||
reminders, err := core.ListReminders(r.Context(), 50)
|
||||
if err != nil {
|
||||
log.Printf("reminders: %v", err)
|
||||
http.Error(w, "reminders error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
renderPage(w, remindersTmpl, map[string]any{"Reminders": reminderRows(reminders)})
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
//go:embed routines.html
|
||||
var routinesHTML string
|
||||
|
||||
// routinesTmpl — the proposed-routine review surface. One row per thing maven
|
||||
// noticed, in her words, with at most two actions: accept or dismiss.
|
||||
var routinesTmpl = parsePage("routines", routinesHTML, nil)
|
||||
|
||||
// routineView is one line on the page: what maven noticed, in her words, and
|
||||
// how long ago she noticed it. A view model, not a database row — the template
|
||||
// never formats an interval or a timestamp itself.
|
||||
type routineView struct {
|
||||
ID int64
|
||||
Phrase string
|
||||
Noticed string
|
||||
}
|
||||
|
||||
// handleRoutines serves the routine review surface (GET) and answers a
|
||||
// proposal (POST id + action=accept|dismiss).
|
||||
//
|
||||
// Accept is gated at step-up, the same tier as enabling a tool: saying yes
|
||||
// hands the trigger loop a new standing reason to speak to the human, so it
|
||||
// moves the boundary and only an authed surface may do it. Dismiss is not
|
||||
// gated — it only ever removes a reason to speak, so the worst a weaker caller
|
||||
// can do is make maven quieter.
|
||||
func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if !requireCore(w, core, "routines") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var msg string
|
||||
if r.Method == http.MethodPost {
|
||||
var ok bool
|
||||
if msg, ok = applyRoutinePost(w, r, core, session, requireStepUp); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
proposed, err := core.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
log.Printf("routines: list: %v", err)
|
||||
http.Error(w, "routines error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
renderPage(w, routinesTmpl, struct {
|
||||
Msg string
|
||||
Proposed []routineView
|
||||
}{msg, toRoutineViews(proposed)})
|
||||
}
|
||||
|
||||
// applyRoutinePost performs one write and returns the message to show. Unlike
|
||||
// the task form, a bad request here is an HTTP status rather than an inline
|
||||
// note, so the second return says whether the response was already written.
|
||||
func applyRoutinePost(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) (string, bool) {
|
||||
ctx := r.Context()
|
||||
action := r.FormValue("action")
|
||||
// "seed" is the one action with no routine to act on — it is what
|
||||
// MAKES a routine (Vikunja #518), so it runs before the id parse. It
|
||||
// lives on this route rather than a page of its own because it is
|
||||
// already the step-up-gated surface for this table, and a second gated
|
||||
// surface is a second thing to get wrong.
|
||||
if action == "seed" {
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return "", false
|
||||
}
|
||||
out, err := seedRoutineEvent(ctx, core, r)
|
||||
if err != nil {
|
||||
log.Printf("routines: seed: %v", err)
|
||||
http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway)
|
||||
return "", false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
idStr := r.FormValue("id")
|
||||
var rid int64
|
||||
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return "", false
|
||||
}
|
||||
switch action {
|
||||
case "accept":
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return "", false
|
||||
}
|
||||
if err := acceptRoutine(ctx, core, rid); err != nil {
|
||||
log.Printf("routines: accept %d: %v", rid, err)
|
||||
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
|
||||
return "", false
|
||||
}
|
||||
return "accepted routine — maven will remind you", true
|
||||
case "dismiss":
|
||||
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
||||
log.Printf("routines: dismiss %d: %v", rid, err)
|
||||
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
||||
return "", false
|
||||
}
|
||||
return "dismissed routine", true
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// toRoutineViews turns the wire rows into view models. The phrase comes from
|
||||
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
|
||||
func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
|
||||
out := make([]routineView, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
|
||||
noticed := "just now"
|
||||
if r.CreatedTs > 0 {
|
||||
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
|
||||
}
|
||||
out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// acceptRoutine marks a proposal accepted. This page is the ONLY surface that
|
||||
// may do it (Vikunja #367): accepting gives the tick loop a standing new
|
||||
// reason to speak, which DESIGN.md puts at layer 3, and the button here is
|
||||
// behind step-up. Voice can park the question and dismiss, never accept.
|
||||
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
||||
proposed, err := core.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var found *ipc.ProposedRoutine
|
||||
for i := range proposed {
|
||||
if proposed[i].ID == id {
|
||||
found = &proposed[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return errors.New("no such proposed routine")
|
||||
}
|
||||
|
||||
// No reminder is created here. Accepting only flips the status; the tick
|
||||
// loop reads accepted routines and nudges on the interval (Vikunja #366).
|
||||
// The old code made a one-shot reminder, so a non-weekly routine fired
|
||||
// once and then went quiet forever.
|
||||
return core.AcceptProposedRoutine(ctx, id)
|
||||
}
|
||||
|
||||
// seedRoutineEvent drives one backdated fact write through core (Vikunja #518),
|
||||
// so the pattern detector can be exercised against a running daemon instead of
|
||||
// over real days. Refused unless mavend was started with -allow-seed; on an
|
||||
// ordinary box the error says so and nothing is written.
|
||||
//
|
||||
// Takes "ago" rather than an absolute timestamp — hours before now, as a float
|
||||
// so a QA sitting can space four seeds three hours apart without doing clock
|
||||
// arithmetic. The detector's floor is two hours, and "0" is a legal answer
|
||||
// meaning now.
|
||||
func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
||||
key := strings.TrimSpace(r.FormValue("key"))
|
||||
value := strings.TrimSpace(r.FormValue("value"))
|
||||
if key == "" || value == "" {
|
||||
return "", errors.New("seed needs a key and a value")
|
||||
}
|
||||
agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("seed: bad ago (hours before now): %w", err)
|
||||
}
|
||||
if agoHours < 0 {
|
||||
return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative")
|
||||
}
|
||||
resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{
|
||||
Key: key,
|
||||
Value: value,
|
||||
Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !resp.Extracted {
|
||||
return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil
|
||||
}
|
||||
if !resp.Proposed {
|
||||
return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil
|
||||
}
|
||||
return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// shellHTML — the shell partial every page is wrapped in: "shellTop", the
|
||||
// "sidebar" it calls, and "shellBottom". It used to be two Go string constants
|
||||
// with the sidebar assembled by a strings.Builder, which is the one piece of
|
||||
// markup that was still concatenated in Go.
|
||||
//
|
||||
// Two template pieces wrap every page:
|
||||
//
|
||||
// {{template "shellTop" "<page-key>"}} ← opens <html>, topbar, sidebar, content
|
||||
// {{template "shellBottom"}} ← closes content, inspector, </html>
|
||||
//
|
||||
// The page-key argument highlights the active sidebar link and sets breadcrumbs.
|
||||
//
|
||||
//go:embed shell.html
|
||||
var shellHTML string
|
||||
|
||||
// sidebarSections maps sidebar section → page entries {label, url, icon}
|
||||
var sidebarSections = []struct {
|
||||
Label string
|
||||
Pages []struct{ Label, URL, Key string }
|
||||
}{
|
||||
{
|
||||
Label: "Workspace",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Dashboard", URL: "/dash", Key: "dash"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Infrastructure",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "History", URL: "/history", Key: "history"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Automation",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
|
||||
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
||||
{Label: "Tasks", URL: "/tasks", Key: "tasks"},
|
||||
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
||||
{Label: "Routines", URL: "/routines", Key: "routines"},
|
||||
{Label: "Morning", URL: "/morning", Key: "morning"},
|
||||
{Label: "Intake", URL: "/events", Key: "events"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Ecosystem",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Siblings", URL: "/ecosystem", Key: "ecosystem"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "AI",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Chat", URL: "/chat", Key: "chat"},
|
||||
{Label: "Voice", URL: "/", Key: "voice"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Settings",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Tools", URL: "/tools", Key: "tools"},
|
||||
{Label: "Model", URL: "/models", Key: "models"},
|
||||
{Label: "Passkey", URL: "/auth/passkey", Key: "passkey"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// pageChrome is the per-page title and ethos-icons.svg symbol id, keyed by the
|
||||
// page key a page hands to shellTop. One table rather than two parallel
|
||||
// switches, so a new page cannot end up with a title and no icon.
|
||||
var pageChrome = map[string]struct{ Title, Icon string }{
|
||||
"dash": {"Dashboard", "i-grid"},
|
||||
"history": {"History", "i-clock"},
|
||||
"trace": {"Rule Trace", "i-wave"},
|
||||
"notifications": {"Notifications", "i-bell"},
|
||||
"tasks": {"Tasks", "i-grid"},
|
||||
"reminders": {"Reminders", "i-calendar"},
|
||||
"routines": {"Routines", "i-repeat"},
|
||||
"morning": {"Morning Routines", "i-calendar"},
|
||||
"chat": {"Chat", "i-message"},
|
||||
"voice": {"Voice", "i-mic"},
|
||||
"ecosystem": {"Ecosystem", "i-grid"},
|
||||
"tools": {"Tools", "i-settings"},
|
||||
"models": {"Resident Model", "i-wave"},
|
||||
"passkey": {"Passkey", "i-lock"},
|
||||
}
|
||||
|
||||
// pageIcon returns the ethos-icons.svg symbol id for the given page. The
|
||||
// sidebar template wraps it in the <use> reference.
|
||||
func pageIcon(key string) string {
|
||||
if c, ok := pageChrome[key]; ok {
|
||||
return c.Icon
|
||||
}
|
||||
return "i-search"
|
||||
}
|
||||
|
||||
// pageTitle returns the human-readable page title for the given key. An
|
||||
// unknown key renders as itself rather than as a blank crumb.
|
||||
func pageTitle(key string) string {
|
||||
if c, ok := pageChrome[key]; ok {
|
||||
return c.Title
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// shellFuncs returns the FuncMap shared by every server-rendered page template.
|
||||
func shellFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"pageTitle": pageTitle,
|
||||
"pageIcon": pageIcon,
|
||||
"sidebarSections": func() any { return sidebarSections },
|
||||
"ago": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "never"
|
||||
}
|
||||
return time.Since(t).Round(time.Second).String() + " ago"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// parsePage parses one server-rendered page: the shell partial plus the page's
|
||||
// own embedded markup, under the shared FuncMap. extra adds page-local
|
||||
// functions (/tools needs capability lookups, /trace a time format) and may be
|
||||
// nil.
|
||||
//
|
||||
// The name is also the page's log label, so a render failure says which page.
|
||||
func parsePage(name, body string, extra template.FuncMap) *template.Template {
|
||||
funcs := shellFuncs()
|
||||
for k, v := range extra {
|
||||
funcs[k] = v
|
||||
}
|
||||
return template.Must(template.New(name).Funcs(funcs).Parse(shellHTML + body))
|
||||
}
|
||||
|
||||
// renderPage writes one page. Every handler sent the same content type and
|
||||
// logged the same way on failure; the header is already written by then, so a
|
||||
// render error can only be logged, never reported.
|
||||
func renderPage(w http.ResponseWriter, t *template.Template, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.Execute(w, data); err != nil {
|
||||
log.Printf("%s render: %v", t.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// requireCore answers whether the surface has a core to read. mavweb runs
|
||||
// without -core (voice-only), and every page that needs mavend says so with a
|
||||
// 503 naming itself rather than a blank error.
|
||||
func requireCore(w http.ResponseWriter, core ipc.CoreAPI, surface string) bool {
|
||||
if core == nil {
|
||||
http.Error(w, surface+" disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// stepUpGate reports whether the caller may proceed through the AuthStepUp
|
||||
// gate, writing the 403 itself when it may not. See stepUpOK for the policy.
|
||||
func stepUpGate(w http.ResponseWriter, session *webauthn.PasskeySession, requireStepUp bool) bool {
|
||||
if stepUpOK(session, requireStepUp) {
|
||||
return true
|
||||
}
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
// stepUpOK is the single decision point for the AuthStepUp gate shared by
|
||||
// POST /tools and POST /api/revert.
|
||||
//
|
||||
// A nil session means WebAuthn is not configured (-webauthn-origin /
|
||||
// -webauthn-rpid unset), so step-up can never be asserted — not merely unmet.
|
||||
// The default is therefore fail-OPEN: gating on an unassertable session would
|
||||
// 403 those surfaces permanently. In that mode the actions rest on the
|
||||
// transport-level auth in front of mavweb (wg+nginx+auth), and main logs a
|
||||
// startup warning naming them. With -require-stepup the same situation fails
|
||||
// CLOSED instead: no assertable step-up ⇒ deny.
|
||||
func stepUpOK(session *webauthn.PasskeySession, requireStepUp bool) bool {
|
||||
if session == nil {
|
||||
return !requireStepUp
|
||||
}
|
||||
return session.IsStepUp()
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
)
|
||||
|
||||
//go:embed tasks.html
|
||||
var tasksHTML string
|
||||
|
||||
var tasksTmpl = parsePage("tasks", tasksHTML, nil)
|
||||
|
||||
// now — the wall clock, indirected so the task page can be rendered at a fixed
|
||||
// instant in a test. internal/tasks is pure and the daemon path already ranks
|
||||
// through a clock it is handed; the page had no reason to be the one surface
|
||||
// that could only be tested at whatever time it happened to run.
|
||||
var now = time.Now
|
||||
|
||||
// resolvedShown — how many finished tasks the page renders. The list is
|
||||
// history, it only grows, and the rows below the first screen are read by
|
||||
// nobody.
|
||||
const resolvedShown = 50
|
||||
|
||||
// taskRow is one line on /tasks, with every timestamp already formatted so the
|
||||
// template holds no date logic.
|
||||
type taskRow struct {
|
||||
ID int64
|
||||
Text string
|
||||
Source string
|
||||
Evidence string
|
||||
Status string
|
||||
Due string
|
||||
Created string
|
||||
Resolved string
|
||||
ResolvedBy string
|
||||
// DueValue and Weight are the raw values the edit form posts back
|
||||
// (Vikunja #509). Due above is for reading and says "—" for no date; a
|
||||
// date input needs "2026-08-07" or the empty string.
|
||||
DueValue string
|
||||
Weight int
|
||||
// Why — the ranker's reason for this row's position (Vikunja #129), in
|
||||
// Russian, empty when nothing distinguished the task. Blank is the honest
|
||||
// rendering: he never said this one mattered more.
|
||||
Why string
|
||||
}
|
||||
|
||||
// rowOf renders one wire task into the shared read-only columns. The two call
|
||||
// sites below add what only they need: the live rows carry the edit form's raw
|
||||
// values and the ranker's reason, the resolved rows carry neither.
|
||||
func rowOf(t ipc.Task) taskRow {
|
||||
return taskRow{
|
||||
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||
ResolvedBy: t.ResolvedBy,
|
||||
}
|
||||
}
|
||||
|
||||
// handleTasks serves the task review surface (GET) and the five writes it
|
||||
// offers (POST): add, edit, confirm, done, drop.
|
||||
//
|
||||
// Not step-up gated, unlike /tools and /routines, and the difference is the
|
||||
// point: enabling a tool defines argv Maven will execute, and accepting a
|
||||
// routine hands the tick loop a new standing reason to interrupt him. A task is
|
||||
// neither — nothing in the tick loop reads the tasks table, so the worst a
|
||||
// weaker caller can do here is write a line onto a list he reads himself. It
|
||||
// still sits behind whatever transport auth fronts mavweb, like every other
|
||||
// page.
|
||||
//
|
||||
// "edit" was re-argued on the same terms rather than inheriting the exemption
|
||||
// (Vikunja #509), and it stays ungated. It rewrites a line on a list he reads
|
||||
// himself, the same blast radius "drop" already has on this page, and the store
|
||||
// refuses the two edits that would cost something: a resolved task keeps the
|
||||
// text it was finished under, and a text collision with another live row is
|
||||
// named instead of merged.
|
||||
//
|
||||
// "confirm" is the only interesting move: it promotes a candidate Maven derived
|
||||
// from something she read into work he owns. That review step is why derived
|
||||
// tasks are captured as candidates in the first place.
|
||||
func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "tasks") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var msg, errMsg string
|
||||
if r.Method == http.MethodPost {
|
||||
var err error
|
||||
msg, err = applyTaskPost(ctx, core, r)
|
||||
if err != nil {
|
||||
log.Printf("tasks: %v", err)
|
||||
errMsg = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
all, err := core.ListTasks(ctx, "")
|
||||
if err != nil {
|
||||
log.Printf("tasks: list: %v", err)
|
||||
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// Live rows are ordered by the same ranker the spoken list uses, so the page
|
||||
// and the voice reply can never disagree about what comes first. Resolved
|
||||
// rows keep store order (newest first) — ranking finished work is pointless.
|
||||
var live []tasks.Item
|
||||
var resolved []taskRow
|
||||
resolvedTotal := 0
|
||||
for _, t := range all {
|
||||
switch t.Status {
|
||||
case "candidate", "open":
|
||||
live = append(live, tasks.Item{
|
||||
ID: t.ID, Text: t.Text, Status: t.Status,
|
||||
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
||||
})
|
||||
default:
|
||||
resolvedTotal++
|
||||
// Finished work is history, and the history only grows. The page
|
||||
// showed every row that ever existed, which is a page that gets
|
||||
// slower every month for a section nobody reads past the top of.
|
||||
if len(resolved) >= resolvedShown {
|
||||
continue
|
||||
}
|
||||
resolved = append(resolved, rowOf(t))
|
||||
}
|
||||
}
|
||||
byID := make(map[int64]ipc.Task, len(all))
|
||||
for _, t := range all {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
var cands, open []taskRow
|
||||
for _, r := range tasks.Rank(live, now()) {
|
||||
t := byID[r.ID]
|
||||
row := rowOf(t)
|
||||
row.DueValue = fmtTaskDateValue(t.Due)
|
||||
row.Weight = t.Weight
|
||||
row.Why = r.Reason
|
||||
if t.Status == "candidate" {
|
||||
// A candidate's due date is Maven's reading of a mail, so its
|
||||
// ranking reason is not shown as if he had set a priority.
|
||||
row.Why = ""
|
||||
cands = append(cands, row)
|
||||
} else {
|
||||
open = append(open, row)
|
||||
}
|
||||
}
|
||||
renderPage(w, tasksTmpl, struct {
|
||||
Msg, Err string
|
||||
Stalls []tasks.Stall
|
||||
Candidates []taskRow
|
||||
Open []taskRow
|
||||
Resolved []taskRow
|
||||
ResolvedMore bool
|
||||
}{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)})
|
||||
}
|
||||
|
||||
// applyTaskPost performs one write and returns the message to show. A bad
|
||||
// request returns an error, which the page renders inline rather than as a
|
||||
// bare 400 — this is a form surface, not an API.
|
||||
func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
||||
action := r.FormValue("action")
|
||||
if action == "add" {
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Weight = wgt
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Due = due
|
||||
resp, err := core.CaptureTask(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.Promoted {
|
||||
return "confirmed a candidate maven had found", nil
|
||||
}
|
||||
if !resp.Created {
|
||||
return "already on the list", nil
|
||||
}
|
||||
return "added task", nil
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid id")
|
||||
}
|
||||
|
||||
if action == "promote" {
|
||||
msg, err := promoteCandidate(ctx, core, r, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
if action == "edit" {
|
||||
// The three fields capture set, and only those (Vikunja #509). Status
|
||||
// is not editable here: that ladder is one-way and has its own buttons.
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch err := core.EditTask(ctx, id, text, due, wgt); {
|
||||
case err == nil:
|
||||
return "saved task", nil
|
||||
case errors.Is(err, ipc.ErrTaskDuplicate):
|
||||
// Naming the collision instead of merging: two live rows carry two
|
||||
// provenances, and picking one is not the page's call.
|
||||
return "", errors.New("another open task already says this — drop one of the two")
|
||||
case errors.Is(err, ipc.ErrTaskResolved):
|
||||
return "", errors.New("a resolved task keeps the text it was finished under")
|
||||
default:
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
var status, msg string
|
||||
switch action {
|
||||
case "confirm":
|
||||
status, msg = "open", "confirmed task"
|
||||
case "done":
|
||||
status, msg = "done", "task done"
|
||||
case "drop":
|
||||
status, msg = "dropped", "dropped task"
|
||||
default:
|
||||
return "", fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
|
||||
return "", statusWriteErr(err)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// errNoDoneWhen — the refusal has to name what is missing, or the button looks
|
||||
// broken. The field it asks for arrives with the intake form (Vikunja #511).
|
||||
var errNoDoneWhen = errors.New("write a definition of done before confirming this candidate")
|
||||
|
||||
// statusWriteErr translates a SetTaskStatus failure into what the page says.
|
||||
func statusWriteErr(err error) error {
|
||||
if errors.Is(err, ipc.ErrTaskNoDoneWhen) {
|
||||
return errNoDoneWhen
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// promoteCandidate turns a candidate into open work with the three things the
|
||||
// board needs (Vikunja #511): a definition of done, an optional blocker, and an
|
||||
// optional date.
|
||||
//
|
||||
// The definition of done is required, and the refusal is the store's — this
|
||||
// only reaches it in a readable order. The blocker is a NAME here and an entity
|
||||
// id in the row: identity lives in Nexus, so the name is resolved first and a
|
||||
// name Nexus cannot resolve stops the promotion instead of being stored.
|
||||
//
|
||||
// A date set here writes a reminder, which is the one unprompted delivery the
|
||||
// persona allows: he asked to be told, on a day he named.
|
||||
func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id int64) (string, error) {
|
||||
doneWhen := strings.TrimSpace(r.FormValue("done_when"))
|
||||
if doneWhen == "" {
|
||||
return "", errors.New("write a definition of done — what has to be true for this to be finished")
|
||||
}
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
blockedOn, err := resolveBlocker(ctx, core, r.FormValue("blocked_on"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if due != nil {
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := core.EditTask(ctx, id, text, due, wgt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := core.SetTaskStatus(ctx, id, "open", now(), "tap:web"); err != nil {
|
||||
return "", statusWriteErr(err)
|
||||
}
|
||||
if due == nil {
|
||||
return "confirmed", nil
|
||||
}
|
||||
// A date-only field has no hour. Nine in the morning, because the reminder
|
||||
// is about a day's work and being told at midnight is being told the night
|
||||
// before.
|
||||
fire := time.Date(due.Year(), due.Month(), due.Day(), 9, 0, 0, 0, due.Location())
|
||||
if _, err := core.CreateReminder(ctx, fire, text, ""); err != nil {
|
||||
// The task IS promoted; only the reminder failed. Saying "confirmed"
|
||||
// and nothing else would leave him expecting a nudge that will not come.
|
||||
return "", fmt.Errorf("confirmed, but the reminder did not save: %w", err)
|
||||
}
|
||||
return "confirmed, and maven will remind you that morning", nil
|
||||
}
|
||||
|
||||
// resolveBlocker turns the blocked-on NAME the form posts into the entity id
|
||||
// the row stores. Identity lives in Nexus, so an unresolvable name stops the
|
||||
// promotion instead of being written as free text. An empty field is no
|
||||
// blocker and reaches Nexus not at all.
|
||||
func resolveBlocker(ctx context.Context, core ipc.CoreAPI, field string) (string, error) {
|
||||
name := strings.TrimSpace(field)
|
||||
if name == "" {
|
||||
return "", nil
|
||||
}
|
||||
ref, err := core.ResolveEntity(ctx, name, []string{"person"})
|
||||
switch {
|
||||
case errors.Is(err, ipc.ErrNotImplemented):
|
||||
return "", errors.New("no identity service here, so blocked-on cannot be stored — leave it empty")
|
||||
case errors.Is(err, ipc.ErrNoEntity):
|
||||
return "", fmt.Errorf("nexus does not know %q", name)
|
||||
case err != nil:
|
||||
return "", fmt.Errorf("resolving %q: %w", name, err)
|
||||
case ref.Ambiguous:
|
||||
// Asking, not picking: a task blocked on the wrong person is a
|
||||
// mistake nobody can see afterwards.
|
||||
return "", fmt.Errorf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", "))
|
||||
}
|
||||
return ref.ID, nil
|
||||
}
|
||||
|
||||
// formWeight reads the importance select. Out-of-range clamps rather than
|
||||
// rejects — a bad select is not worth a 400 — but trailing garbage is refused,
|
||||
// because strconv is not Sscanf and "3junk" is not a 3.
|
||||
func formWeight(r *http.Request) (int, error) {
|
||||
v := r.FormValue("weight")
|
||||
if v == "" {
|
||||
return 0, nil
|
||||
}
|
||||
wgt, err := strconv.Atoi(v)
|
||||
if err != nil || wgt < 0 {
|
||||
return 0, fmt.Errorf("bad weight %q", v)
|
||||
}
|
||||
if wgt > tasks.MaxWeight {
|
||||
wgt = tasks.MaxWeight
|
||||
}
|
||||
return wgt, nil
|
||||
}
|
||||
|
||||
// formDue reads the date input. An empty field is nil, which on an edit means
|
||||
// "clear the date" — the form has no other way to say it.
|
||||
func formDue(r *http.Request, now time.Time) (*time.Time, error) {
|
||||
d := r.FormValue("due")
|
||||
if d == "" {
|
||||
return nil, nil
|
||||
}
|
||||
due, err := time.ParseInLocation("2006-01-02", d, now.Location())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad due date %q", d)
|
||||
}
|
||||
return &due, nil
|
||||
}
|
||||
|
||||
// fmtTaskDateValue renders a due date the way <input type=date> requires, or
|
||||
// "" for no date. Separate from fmtTaskDate, which renders it for reading.
|
||||
func fmtTaskDateValue(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Local().Format("2006-01-02")
|
||||
}
|
||||
|
||||
func fmtTaskTime(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.Local().Format("02 Jan 15:04")
|
||||
}
|
||||
|
||||
func fmtTaskDate(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.Local().Format("02 Jan")
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
//go:embed tools.html
|
||||
var toolsHTML string
|
||||
|
||||
// toolsTmpl — the enable surface. Server-rendered, no JS: a plain HTML form
|
||||
// POSTs back to /tools to enable a proposal. html/template escapes tool names +
|
||||
// utterances (they came from voice STT — untrusted text).
|
||||
var toolsTmpl = parsePage("tools", toolsHTML, template.FuncMap{
|
||||
"join": strings.Join,
|
||||
"capability": func(t ipc.Tool) string { return tool.CapabilityOf(t).String() },
|
||||
"risk": func(t ipc.Tool) string { return string(tool.RiskOf(t)) },
|
||||
})
|
||||
|
||||
// handleTools serves the enable surface (GET) and applies an enable (POST).
|
||||
// POST fields: name, cmd (space-separated argv), destructive (checkbox). cmd is
|
||||
// whitespace-split — argv with embedded spaces isn't supported (ponytail: no
|
||||
// shell-word parsing; the box owner controls this input, quote a wrapper script
|
||||
// if an arg needs spaces).
|
||||
func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if !requireCore(w, core, "tools") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var msg string
|
||||
if r.Method == http.MethodPost {
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
action := r.FormValue("action")
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
switch action {
|
||||
case "enable":
|
||||
scope := r.FormValue("scope")
|
||||
cmd := strings.Fields(r.FormValue("cmd"))
|
||||
destructive := r.FormValue("destructive") != ""
|
||||
if name == "" || len(cmd) == 0 {
|
||||
http.Error(w, "name and cmd required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := core.EnableTool(ctx, name, cmd, destructive, scope, time.Now()); err != nil {
|
||||
log.Printf("tools: enable %q: %v", name, err)
|
||||
http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
msg = "enabled " + name
|
||||
case "disable":
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := core.DisableTool(ctx, name); err != nil {
|
||||
log.Printf("tools: disable %q: %v", name, err)
|
||||
http.Error(w, "disable failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
msg = "disabled " + name
|
||||
case "dismiss":
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := core.DeleteTool(ctx, name); err != nil {
|
||||
log.Printf("tools: dismiss %q: %v", name, err)
|
||||
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
msg = "dismissed " + name
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
proposed, err1 := core.ListTools(ctx, "proposed")
|
||||
enabled, err2 := core.ListTools(ctx, "enabled")
|
||||
if err := cmp.Or(err1, err2); err != nil {
|
||||
log.Printf("tools: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// MCP is off by default and an older core may not know the method at all,
|
||||
// so a failure here renders an empty section rather than breaking the page.
|
||||
servers, err := core.MCPServers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tools: mcp servers: %v", err)
|
||||
servers = nil
|
||||
}
|
||||
// Enabled rows are shown grouped by capability domain (Vikunja #452). A
|
||||
// flat list stops answering "what can she do to the house" somewhere
|
||||
// around fifteen rows, and that is the question this page exists for.
|
||||
renderPage(w, toolsTmpl, struct {
|
||||
Msg string
|
||||
Proposed []ipc.Tool
|
||||
Enabled []ipc.Tool
|
||||
Groups []tool.CapabilityGroup
|
||||
MCP []ipc.MCPServerStatus
|
||||
}{msg, proposed, enabled, tool.GroupByDomain(enabled), servers})
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// The two proxies onto mavend's voice port: GET /ws streams turns over a
|
||||
// websocket, POST /api/ptt does one turn over plain HTTP. Both carry the same
|
||||
// step-up gate, because speaking an act is not a smaller act than typing one
|
||||
// (Vikunja #317). The length-prefixed framing they share is at the bottom.
|
||||
|
||||
// maxFrame caps a single voice frame in either direction.
|
||||
const maxFrame = 64 << 20
|
||||
|
||||
// pushToTalk builds the one request either proxy sends. Surface is
|
||||
// SurfacePCClient for both: the browser is standing in for the PC client.
|
||||
func pushToTalk(pcm []byte) voice.Request {
|
||||
return voice.Request{
|
||||
ID: uint64(time.Now().UnixNano()),
|
||||
Method: voice.MethodPushToTalk,
|
||||
Params: mustMarshal(voice.PushToTalkReq{
|
||||
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm},
|
||||
Lang: "mixed",
|
||||
Surface: voice.SurfacePCClient,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: []string{"*"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ws accept: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "bye")
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
var d net.Dialer
|
||||
tc, err := d.DialContext(ctx, "tcp", voiceAddr)
|
||||
if err != nil {
|
||||
log.Printf("dial voice: %v", err)
|
||||
writeWSErr(conn, ctx, "voice unavailable")
|
||||
return
|
||||
}
|
||||
defer tc.Close()
|
||||
|
||||
for {
|
||||
_, msg, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
log.Printf("ws read: %v", err)
|
||||
return
|
||||
}
|
||||
if len(msg) < 4 {
|
||||
log.Printf("ws msg too short (%d bytes)", len(msg))
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("ws got %d bytes from client", len(msg))
|
||||
req := pushToTalk(msg)
|
||||
if err := writeFrame(tc, &req); err != nil {
|
||||
log.Printf("write voice req: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Read frames until we get the matching Response (handling any interleaved Pushes)
|
||||
for {
|
||||
resp, push, err := readOneFrame(tc)
|
||||
if err != nil {
|
||||
log.Printf("read voice: %v", err)
|
||||
return
|
||||
}
|
||||
if push != nil {
|
||||
data, _ := json.Marshal(push)
|
||||
conn.Write(ctx, websocket.MessageText, data)
|
||||
continue
|
||||
}
|
||||
if resp.Error != nil {
|
||||
writeWSErr(conn, ctx, resp.Error.Message)
|
||||
break
|
||||
}
|
||||
var pttResp voice.PushToTalkResp
|
||||
if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
|
||||
log.Printf("unmarshal resp: %v", err)
|
||||
break
|
||||
}
|
||||
if pttResp.ReplyText != "" {
|
||||
conn.Write(ctx, websocket.MessageText, []byte(pttResp.ReplyText))
|
||||
}
|
||||
if len(pttResp.ReplyAudio.Bytes) > 0 {
|
||||
conn.Write(ctx, websocket.MessageBinary, pttResp.ReplyAudio.Bytes)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", 405)
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
if len(body) < 4 {
|
||||
http.Error(w, "too short", 400)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("ptt got %d bytes from client", len(body))
|
||||
|
||||
var d net.Dialer
|
||||
tc, err := d.DialContext(r.Context(), "tcp", voiceAddr)
|
||||
if err != nil {
|
||||
log.Printf("ptt dial voice: %v", err)
|
||||
http.Error(w, "voice unavailable", 503)
|
||||
return
|
||||
}
|
||||
defer tc.Close()
|
||||
|
||||
req := pushToTalk(body)
|
||||
if err := writeFrame(tc, &req); err != nil {
|
||||
log.Printf("ptt write: %v", err)
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
resp, push, err := readOneFrame(tc)
|
||||
if err != nil {
|
||||
log.Printf("ptt read: %v", err)
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
if push != nil {
|
||||
continue
|
||||
}
|
||||
if resp.Error != nil {
|
||||
http.Error(w, resp.Error.Message, 500)
|
||||
return
|
||||
}
|
||||
var pttResp voice.PushToTalkResp
|
||||
if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1")
|
||||
// PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space
|
||||
// as "+", which is form encoding, and the client decodes this header
|
||||
// with decodeURIComponent, which only knows "%20" — so every space in a
|
||||
// spoken reply reached the on-page log as a plus sign. PathEscape is the
|
||||
// flavour decodeURIComponent actually reverses, which keeps the encoding
|
||||
// a property of the header rather than something the client has to know.
|
||||
w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText))
|
||||
w.Write(pttResp.ReplyAudio.Bytes)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func writeWSErr(conn *websocket.Conn, ctx context.Context, msg string) {
|
||||
conn.Write(ctx, websocket.MessageText, []byte(`{"error":"`+msg+`"}`))
|
||||
}
|
||||
|
||||
func writeFrame(w io.Writer, v any) error {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
if len(body) > maxFrame {
|
||||
return fmt.Errorf("frame too large: %d", len(body))
|
||||
}
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
|
||||
if _, err := w.Write(hdr[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
func readFrame(r io.Reader, v any) error {
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
n := binary.BigEndian.Uint32(hdr[:])
|
||||
if n > maxFrame {
|
||||
return fmt.Errorf("frame too large: %d", n)
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(buf, v)
|
||||
}
|
||||
|
||||
func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) {
|
||||
var raw struct {
|
||||
ID uint64 `json:"id"`
|
||||
Result json.RawMessage `json:"r,omitempty"`
|
||||
Error *voice.RpcError `json:"e,omitempty"`
|
||||
Kind voice.PushKind `json:"kind,omitempty"`
|
||||
Params json.RawMessage `json:"p,omitempty"`
|
||||
}
|
||||
if err := readFrame(r, &raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if raw.Kind != "" && raw.ID == 0 {
|
||||
return nil, &voice.Push{Kind: raw.Kind, Params: raw.Params}, nil
|
||||
}
|
||||
return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil
|
||||
}
|
||||
|
||||
func mustMarshal(v any) json.RawMessage {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
+10
-61
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -76,69 +77,17 @@ func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string, s
|
||||
// on: assert here (bumps the daemon session to L3 for the assertion TTL), then
|
||||
// enable a tool on /tools within that window.
|
||||
func (h *PasskeyHandle) Page(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
passkeyTmpl.Execute(w, nil)
|
||||
renderPage(w, passkeyTmpl, nil)
|
||||
}
|
||||
|
||||
// passkeyPageHTML — rendered via passkeyTmpl (main.go) which wraps with shellTop/shellBottom.
|
||||
const passkeyPageHTML = `{{template "shellTop" "passkey"}}
|
||||
<h1>Passkey</h1>
|
||||
<p class=hint>Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.</p>
|
||||
<div class=flex gap-2>
|
||||
<button class=btn onclick=enroll()>enroll passkey</button>
|
||||
<button class=btn onclick=assert()>assert (step-up)</button>
|
||||
<button class=btn onclick=rewrapKey()>rewrite cold-start key</button>
|
||||
<a href=/tools><button class=btn-primary>→ tools</button></a>
|
||||
</div>
|
||||
<p class=hint>Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.</p>
|
||||
<div id=msg></div>
|
||||
{{template "shellBottom"}}
|
||||
<script>
|
||||
const b64u=b=>btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||
const ub64=s=>{s=s.replace(/-/g,'+').replace(/_/g,'/');const b=atob(s),a=new Uint8Array(b.length);for(let i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return a;};
|
||||
const say=(t,ok)=>{const m=document.getElementById('msg');m.textContent=t;m.className=ok?'msg msg-ok':'msg msg-err';};
|
||||
async function enroll(){try{
|
||||
const {challenge,options}=await (await fetch('/auth/webauthn/register/begin')).json();
|
||||
options.challenge=ub64(options.challenge);
|
||||
options.user.id=ub64(options.user.id);
|
||||
const c=await navigator.credentials.create({publicKey:options});
|
||||
const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
||||
clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})});
|
||||
if(!r.ok){say('enroll failed: '+await r.text(),false);return;}
|
||||
// The wrapped key can only be written from an assertion: PRF results are
|
||||
// not produced at create() time on most authenticators. Enrolment reports
|
||||
// whether PRF is available at all so he is not told cold-start works when
|
||||
// it cannot.
|
||||
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||
const prfOK=!!(ext.prf&&ext.prf.enabled);
|
||||
say(prfOK?'enrolled ✓ — now assert once to write the cold-start key':
|
||||
'enrolled ✓ — but this authenticator has no PRF: cold-start unlock unavailable',true);
|
||||
}catch(e){say('enroll error: '+e,false);}}
|
||||
async function assert(explicit){try{
|
||||
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
|
||||
options.challenge=ub64(options.challenge);
|
||||
const c=await navigator.credentials.get({publicKey:options});
|
||||
// The PRF result is the cold-start secret. It never touches localStorage
|
||||
// and is posted once, over the same request as the assertion.
|
||||
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||
const prf=ext.prf&&ext.prf.results&&ext.prf.results.first?b64u(ext.prf.results.first):'';
|
||||
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify({challenge,prf,explicit:!!explicit,credential:{id:c.id,type:c.type,response:{
|
||||
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
|
||||
signature:b64u(c.response.signature)}}})});
|
||||
if(!r.ok){say('assert failed: '+await r.text(),false);return;}
|
||||
if(!prf){say('stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true);return;}
|
||||
say(explicit?'stepped up ✓ — cold-start key now points at this passkey':
|
||||
'stepped up ✓ — enable tools now',true);
|
||||
}catch(e){say('assert error: '+e,false);}}
|
||||
// Rewriting the wrapped key is a separate gesture, never a side effect of a
|
||||
// step-up. Only this button sets explicit, and only explicit lets the daemon
|
||||
// replace a blob that already exists.
|
||||
async function rewrapKey(){
|
||||
if(!confirm('Rewrite the cold-start key under the passkey you are about to assert? Every other enrolled passkey stops being able to unlock a cold-booted daemon.'))return;
|
||||
await assert(true);}
|
||||
</script>`
|
||||
// passkeyPageHTML — the enrolment page's own markup, wrapped by passkeyTmpl
|
||||
// with shellTop/shellBottom. It was a Go string constant, which is the one
|
||||
// place page markup still lived in Go.
|
||||
//
|
||||
//go:embed passkey.html
|
||||
var passkeyPageHTML string
|
||||
|
||||
var passkeyTmpl = parsePage("passkey", passkeyPageHTML, nil)
|
||||
|
||||
func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
opts, challenge, err := h.rp.CreationOptions([]byte("maven-user"), "maven user")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Nine claimants interrupting a parked reminder
|
||||
|
||||
Measured 2026-08-06 on homesrv against master at `70b32af`, so after V-560, V-565, V-567
|
||||
and V-572. Probed through `POST /api/chat` on mavweb, three turns per scenario, an explicit
|
||||
`отмена` between scenarios so each starts from an empty pending slot.
|
||||
|
||||
The question this answers: the Rome failure was one claimant eating one question. How many
|
||||
other claimants do the same, and does routing first fix all of them or only weather?
|
||||
|
||||
## Time forms, no pending state
|
||||
|
||||
V-572 holds for every spoken shape, not just the one it was measured on.
|
||||
|
||||
| said | answered |
|
||||
|---|---|
|
||||
| `напомни в 11:00 позвонить маме` | `хорошо, напомню сегодня в 11:00.` |
|
||||
| `напомни в 9 позвонить маме` | `хорошо, напомню сегодня в 09:00.` |
|
||||
| `напомни в 9 утра позвонить маме` | `хорошо, напомню сегодня в 09:00.` |
|
||||
| `напомни завтра в 9 купить молоко` | `хорошо, напомню завтра в 09:00.` |
|
||||
| `напомни в девять позвонить маме` | `хорошо, напомню сегодня в 09:00.` |
|
||||
| `напомни через час выпить воды` | `хорошо, напомню сегодня в 02:26.` |
|
||||
|
||||
The spelled-out `девять` works, which is the lexicon's oblique cardinals doing their job.
|
||||
|
||||
## The nine scenarios
|
||||
|
||||
Each opens with a reminder that names no time, so a clarify is parked. Then a different
|
||||
claimant speaks.
|
||||
|
||||
| | interrupted by | claiming source | flow | verdict |
|
||||
|---|---|---|---|---|
|
||||
| A | `какая сейчас погода в Риме?` | weather | dropped, announced | question answered |
|
||||
| B | `что я говорил про отпуск?` | personal | dropped, announced | question answered |
|
||||
| C | `какая столица Франции?` | search | dropped, announced | question answered |
|
||||
| D | `у меня новый ноутбук` | none | kept | **note silently discarded** |
|
||||
| E | `что у меня сегодня?` | none | completed wrongly | **reminder invented at the clock** |
|
||||
| F | `что требует внимания?` | none | dropped, announced | question answered |
|
||||
| G | `напомни завтра в 9 купить молоко` | none | dropped, announced | new reminder set |
|
||||
| H | reminder said whole, then weather, then a repair | weather | none parked | **repair invents a note** |
|
||||
| I | `сколько времени?` | none | dropped, announced | question answered |
|
||||
|
||||
Six of nine answer the interrupting utterance. That is what V-560 bought, and it is the
|
||||
half of Rome that was broken.
|
||||
|
||||
## The three that fail
|
||||
|
||||
**E is the worst of the nine.**
|
||||
|
||||
```text
|
||||
me: напомни забрать посылку
|
||||
maven: Когда?
|
||||
me: что у меня сегодня?
|
||||
maven: хорошо, напомню сегодня в 01:28.
|
||||
```
|
||||
|
||||
The agenda question is never answered and a reminder exists that nobody asked for, at the
|
||||
current minute. Asked with nothing parked, the same sentence is answered by the calendar.
|
||||
So stage 0 routes it correctly and the pending claimant runs in front of that route. This
|
||||
is worse than the pre-V-560 Rome behaviour, which at least failed audibly. Filed as V-577.
|
||||
|
||||
**D drops a note without saying so.**
|
||||
|
||||
```text
|
||||
me: напомни позвонить врачу
|
||||
maven: Когда?
|
||||
me: у меня новый ноутбук
|
||||
maven: Во сколько напомнить?
|
||||
```
|
||||
|
||||
Nothing wrong is written, and nothing right is either. Alone the sentence is stored. Also
|
||||
V-577.
|
||||
|
||||
**H writes a note nobody dictated.**
|
||||
|
||||
```text
|
||||
нет, не маме, а папе -> Сохранила заметку о том, что ты поедешь на дачу.
|
||||
```
|
||||
|
||||
Reproduced with no pending state at all, twice, with two different inventions. The stored
|
||||
body is generated rather than captured. Filed as V-576. It is the only defect here that outlives
|
||||
the turn. A wrong note is indexed and comes back later as recall.
|
||||
|
||||
## What the drop notice looks like now
|
||||
|
||||
Six scenarios answer the question and say `Прошлую просьбу отпускаю.` first. That sentence
|
||||
is rejected by the owner. V-561 replaces it with suspend and resume for a side query, and
|
||||
keeps it for a new request (G) and a cancel.
|
||||
|
||||
## Reading of the set
|
||||
|
||||
The pattern behind Rome, V-567 and V-577 is one pattern. The claimant that knows least
|
||||
about the utterance holds the earliest and strongest trigger. V-560 moved the routing
|
||||
in front of one resolver. The other roles still decide before the route is read.
|
||||
@@ -207,23 +207,26 @@ func (s *ClarifyStore) Depth(id string) int {
|
||||
return len(s.stacks[id])
|
||||
}
|
||||
|
||||
// TakeExpired reports whether a question was parked here but its TTL ran out,
|
||||
// and drops it. Get drops such a question silently, which leaves the user
|
||||
// thinking his request is still alive — the caller uses this to tell him it is
|
||||
// gone before treating his words as a fresh utterance.
|
||||
// TakeExpired reports HOW MANY parked questions were dropped because the TTL
|
||||
// ran out, and drops them. 0 ⇒ nothing was parked, or what was parked is still
|
||||
// live. Get drops such a question silently, which leaves the user thinking his
|
||||
// request is still alive — the caller uses this to tell him it is gone before
|
||||
// treating his words as a fresh utterance.
|
||||
//
|
||||
// It looks at the top only, and drops the whole stack when that one is dead: one
|
||||
// notice is what a reply can carry, and anything parked under a question that
|
||||
// timed out has been waiting at least as long.
|
||||
func (s *ClarifyStore) TakeExpired(id string, now time.Time) bool {
|
||||
// It looks at the top only, and drops the whole stack when that one is dead:
|
||||
// anything parked under a question that timed out has been waiting at least as
|
||||
// long. The COUNT rather than a bool since V-561, because the stack can now
|
||||
// hold two — the flow and the side query that suspended it — and a notice
|
||||
// saying "прошлую просьбу" when two died is a lie about the count.
|
||||
func (s *ClarifyStore) TakeExpired(id string, now time.Time) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
stack := s.stacks[id]
|
||||
if len(stack) == 0 || !stack[len(stack)-1].IsExpired(now) {
|
||||
return false
|
||||
return 0
|
||||
}
|
||||
delete(s.stacks, id)
|
||||
return true
|
||||
return len(stack)
|
||||
}
|
||||
|
||||
// Delete drops every question parked for this id. The old single-slot Delete
|
||||
|
||||
@@ -64,21 +64,21 @@ func TestClarifyStoreGetPutDelete(t *testing.T) {
|
||||
// whose TTL ran out.
|
||||
func TestClarifyStoreTakeExpired(t *testing.T) {
|
||||
s := NewClarifyStore(time.Minute)
|
||||
if s.TakeExpired("voice", base) {
|
||||
t.Fatal("nothing parked ⇒ nothing expired")
|
||||
if n := s.TakeExpired("voice", base); n != 0 {
|
||||
t.Fatalf("nothing parked ⇒ nothing expired, got %d", n)
|
||||
}
|
||||
s.Put("voice", &PendingQuestion{Missing: []Slot{SlotTime}, Asked: base, TTL: time.Minute})
|
||||
if s.TakeExpired("voice", base.Add(30*time.Second)) {
|
||||
t.Fatal("a live question must not report as expired")
|
||||
if n := s.TakeExpired("voice", base.Add(30*time.Second)); n != 0 {
|
||||
t.Fatalf("a live question must not report as expired, got %d", n)
|
||||
}
|
||||
if s.Get("voice", base.Add(30*time.Second)) == nil {
|
||||
t.Fatal("a live question must survive TakeExpired")
|
||||
}
|
||||
if !s.TakeExpired("voice", base.Add(2*time.Minute)) {
|
||||
t.Fatal("a stale question must report as expired")
|
||||
if n := s.TakeExpired("voice", base.Add(2*time.Minute)); n != 1 {
|
||||
t.Fatalf("a stale question must report as one expired, got %d", n)
|
||||
}
|
||||
if s.TakeExpired("voice", base.Add(2*time.Minute)) {
|
||||
t.Fatal("TakeExpired must drop the question, so the second call is false")
|
||||
if n := s.TakeExpired("voice", base.Add(2*time.Minute)); n != 0 {
|
||||
t.Fatalf("TakeExpired must drop the question, so the second call is 0, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,14 +116,16 @@ func TestStackExpiryDropsTheStackAndIsReported(t *testing.T) {
|
||||
|
||||
s.Push("voice", parked("напомни", pendingBase))
|
||||
s.Push("voice", parked("погода", pendingBase))
|
||||
if !s.TakeExpired("voice", late) {
|
||||
t.Error("TakeExpired did not report the timed-out exchange")
|
||||
// Two died, and the count says two: the notice that reports this has a
|
||||
// plural wording since V-561, and it is chosen off this number.
|
||||
if n := s.TakeExpired("voice", late); n != 2 {
|
||||
t.Errorf("TakeExpired reported %d timed-out questions, want 2", n)
|
||||
}
|
||||
if s.Depth("voice") != 0 {
|
||||
t.Error("TakeExpired left entries behind")
|
||||
}
|
||||
if s.TakeExpired("voice", late) {
|
||||
t.Error("TakeExpired reported twice")
|
||||
if n := s.TakeExpired("voice", late); n != 0 {
|
||||
t.Errorf("TakeExpired reported twice: %d", n)
|
||||
}
|
||||
// Pop of an expired top yields nothing rather than a dead action.
|
||||
s.Push("voice", parked("напомни", pendingBase))
|
||||
|
||||
+6
-9
@@ -594,7 +594,7 @@ type setTaskFieldsReq struct {
|
||||
BlockedOn string `json:"blocked_on,omitempty"`
|
||||
}
|
||||
|
||||
// idReq — methods keyed by a single id.
|
||||
// idReq — methods keyed by a single id, which is every routine transition.
|
||||
type idReq struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
@@ -652,6 +652,11 @@ type calendarEventsReq struct {
|
||||
type revertReq struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// revertResp — the id of the voiding fact the revert wrote.
|
||||
type revertResp struct {
|
||||
NewID int64 `json:"new_id"`
|
||||
}
|
||||
type writeNoteReq struct {
|
||||
Ts time.Time `json:"ts"`
|
||||
Text string `json:"text"`
|
||||
@@ -780,14 +785,6 @@ 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.
|
||||
|
||||
@@ -649,11 +649,11 @@ func (c *Client) ModelStatus(ctx context.Context) (ModelStatusResp, error) {
|
||||
}
|
||||
|
||||
func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
||||
return c.call(ctx, MethodDismissProposedRoutine, idReq{ID: id}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
|
||||
return c.call(ctx, MethodAcceptProposedRoutine, idReq{ID: id}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Chat(ctx context.Context, conversation, text string) (ChatReply, error) {
|
||||
@@ -713,13 +713,11 @@ func (c *Client) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
}
|
||||
|
||||
func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||
var result struct {
|
||||
NewID int64 `json:"new_id"`
|
||||
}
|
||||
if err := c.call(ctx, MethodRevertFact, map[string]string{"key": key}, &result); err != nil {
|
||||
var r revertResp
|
||||
if err := c.call(ctx, MethodRevertFact, revertReq{Key: key}, &r); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.NewID, nil
|
||||
return r.NewID, nil
|
||||
}
|
||||
|
||||
// Ping asks whether the daemon is there, and whether it is locked. It is not a
|
||||
|
||||
+126
-264
@@ -335,6 +335,22 @@ func withoutParams[R any](fn func(ctx context.Context, api CoreAPI) (R, error))
|
||||
}
|
||||
}
|
||||
|
||||
// withParamsSlice is withParams for a list read. It replaces a nil slice with
|
||||
// an empty one so the wire carries [] rather than null, which every reader of
|
||||
// these methods relies on.
|
||||
func withParamsSlice[P any, E any](fn func(ctx context.Context, api CoreAPI, p P) ([]E, error)) handlerFunc {
|
||||
return withParams(func(ctx context.Context, api CoreAPI, p P) ([]E, error) {
|
||||
out, err := fn(ctx, api, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []E{}
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
// methodTable — one entry per CoreAPI-backed method. Built once at package
|
||||
// init, not per-Server and not per-dispatch: entries close over nothing but
|
||||
// the CoreAPI method being called, and dispatch passes in the *current*
|
||||
@@ -373,15 +389,8 @@ var methodTable = map[Method]handlerFunc{
|
||||
MethodMarkReminder: withParamsVoid(func(ctx context.Context, api CoreAPI, p markReminderReq) error {
|
||||
return api.MarkReminder(ctx, p.ID, p.Status)
|
||||
}),
|
||||
MethodListReminders: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Reminder, error) {
|
||||
out, err := api.ListReminders(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Reminder{}
|
||||
}
|
||||
return out, nil
|
||||
MethodListReminders: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]Reminder, error) {
|
||||
return api.ListReminders(ctx, p.N)
|
||||
}),
|
||||
MethodRecordNudge: withParams(func(ctx context.Context, api CoreAPI, p recordNudgeReq) (idResp, error) {
|
||||
id, err := api.RecordNudge(ctx, p.Rule, p.Channel, p.Message, p.Ts)
|
||||
@@ -390,109 +399,39 @@ var methodTable = map[Method]handlerFunc{
|
||||
MethodResolveNudge: withParamsVoid(func(ctx context.Context, api CoreAPI, p resolveNudgeReq) error {
|
||||
return api.ResolveNudge(ctx, p.ID, p.Outcome, p.Ts)
|
||||
}),
|
||||
MethodRecentOutcomes: withParams(func(ctx context.Context, api CoreAPI, p outcomesReq) ([]string, error) {
|
||||
out, err := api.RecentOutcomes(ctx, p.Rule, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []string{} // stable non-null on the wire
|
||||
}
|
||||
return out, nil
|
||||
MethodRecentOutcomes: withParamsSlice(func(ctx context.Context, api CoreAPI, p outcomesReq) ([]string, error) {
|
||||
return api.RecentOutcomes(ctx, p.Rule, p.N)
|
||||
}),
|
||||
MethodRecentFacts: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Fact, error) {
|
||||
out, err := api.RecentFacts(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Fact{}
|
||||
}
|
||||
return out, nil
|
||||
MethodRecentFacts: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]Fact, error) {
|
||||
return api.RecentFacts(ctx, p.N)
|
||||
}),
|
||||
MethodRecentActiveFacts: withParams(func(ctx context.Context, api CoreAPI, p kindNReq) ([]Fact, error) {
|
||||
out, err := api.RecentActiveFactsByKind(ctx, p.Kind, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Fact{}
|
||||
}
|
||||
return out, nil
|
||||
MethodRecentActiveFacts: withParamsSlice(func(ctx context.Context, api CoreAPI, p kindNReq) ([]Fact, error) {
|
||||
return api.RecentActiveFactsByKind(ctx, p.Kind, p.N)
|
||||
}),
|
||||
MethodCalendarEvents: withParams(func(ctx context.Context, api CoreAPI, p calendarEventsReq) ([]Fact, error) {
|
||||
out, err := api.CalendarEvents(ctx, p.From, p.To)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Fact{}
|
||||
}
|
||||
return out, nil
|
||||
MethodCalendarEvents: withParamsSlice(func(ctx context.Context, api CoreAPI, p calendarEventsReq) ([]Fact, error) {
|
||||
return api.CalendarEvents(ctx, p.From, p.To)
|
||||
}),
|
||||
MethodRecentEcoTraces: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]EcosystemTrace, error) {
|
||||
out, err := api.RecentEcosystemTraces(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []EcosystemTrace{}
|
||||
}
|
||||
return out, nil
|
||||
MethodRecentEcoTraces: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]EcosystemTrace, error) {
|
||||
return api.RecentEcosystemTraces(ctx, p.N)
|
||||
}),
|
||||
MethodDeliveryAttempts: withParams(func(ctx context.Context, api CoreAPI, p deliveryAttemptsReq) ([]DeliveryAttempt, error) {
|
||||
out, err := api.DeliveryAttempts(ctx, p.Status, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []DeliveryAttempt{}
|
||||
}
|
||||
return out, nil
|
||||
MethodDeliveryAttempts: withParamsSlice(func(ctx context.Context, api CoreAPI, p deliveryAttemptsReq) ([]DeliveryAttempt, error) {
|
||||
return api.DeliveryAttempts(ctx, p.Status, p.N)
|
||||
}),
|
||||
MethodRecentNudges: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) {
|
||||
out, err := api.RecentNudges(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Nudge{}
|
||||
}
|
||||
return out, nil
|
||||
MethodRecentNudges: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) {
|
||||
return api.RecentNudges(ctx, p.N)
|
||||
}),
|
||||
MethodWriteNote: withParams(func(ctx context.Context, api CoreAPI, p writeNoteReq) (idResp, error) {
|
||||
id, err := api.WriteNote(ctx, p.Ts, p.Text, p.Embedding, p.Source)
|
||||
return idResp{ID: id}, err
|
||||
}),
|
||||
MethodQueryNotes: withParams(func(ctx context.Context, api CoreAPI, p queryNotesReq) ([]Note, error) {
|
||||
out, err := api.QueryNotes(ctx, p.Embedding, p.K)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Note{}
|
||||
}
|
||||
return out, nil
|
||||
MethodQueryNotes: withParamsSlice(func(ctx context.Context, api CoreAPI, p queryNotesReq) ([]Note, error) {
|
||||
return api.QueryNotes(ctx, p.Embedding, p.K)
|
||||
}),
|
||||
MethodRecentNotesFromSource: withParams(func(ctx context.Context, api CoreAPI, p sourceNReq) ([]Note, error) {
|
||||
out, err := api.RecentNotesFromSource(ctx, p.Prefix, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Note{}
|
||||
}
|
||||
return out, nil
|
||||
MethodRecentNotesFromSource: withParamsSlice(func(ctx context.Context, api CoreAPI, p sourceNReq) ([]Note, error) {
|
||||
return api.RecentNotesFromSource(ctx, p.Prefix, p.N)
|
||||
}),
|
||||
MethodRecentNotes: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Note, error) {
|
||||
out, err := api.RecentNotes(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Note{}
|
||||
}
|
||||
return out, nil
|
||||
MethodRecentNotes: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]Note, error) {
|
||||
return api.RecentNotes(ctx, p.N)
|
||||
}),
|
||||
MethodProposeTool: withParams(func(ctx context.Context, api CoreAPI, p proposeToolReq) (proposeToolResp, error) {
|
||||
ok, err := api.ProposeTool(ctx, p.Name, p.Utterance, p.Scope, p.Ts)
|
||||
@@ -563,18 +502,15 @@ var methodTable = map[Method]handlerFunc{
|
||||
}
|
||||
return listProposedRoutinesResp{Routines: out}, nil
|
||||
}),
|
||||
MethodDismissProposedRoutine: withParamsVoid(func(ctx context.Context, api CoreAPI, p dismissProposedRoutineReq) error {
|
||||
MethodDismissProposedRoutine: withParamsVoid(func(ctx context.Context, api CoreAPI, p idReq) error {
|
||||
return api.DismissProposedRoutine(ctx, p.ID)
|
||||
}),
|
||||
MethodAcceptProposedRoutine: withParamsVoid(func(ctx context.Context, api CoreAPI, p acceptProposedRoutineReq) error {
|
||||
MethodAcceptProposedRoutine: withParamsVoid(func(ctx context.Context, api CoreAPI, p idReq) error {
|
||||
return api.AcceptProposedRoutine(ctx, p.ID)
|
||||
}),
|
||||
MethodRevertFact: withParams(func(ctx context.Context, api CoreAPI, p revertReq) (map[string]int64, error) {
|
||||
MethodRevertFact: withParams(func(ctx context.Context, api CoreAPI, p revertReq) (revertResp, error) {
|
||||
newID, err := api.RevertFact(ctx, p.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]int64{"new_id": newID}, nil
|
||||
return revertResp{NewID: newID}, err
|
||||
}),
|
||||
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
|
||||
reply, err := api.Chat(ctx, p.Conversation, p.Text)
|
||||
@@ -599,15 +535,8 @@ var methodTable = map[Method]handlerFunc{
|
||||
MethodMorningStatus: withoutParams(func(ctx context.Context, api CoreAPI) ([]MorningRoutineStatus, error) {
|
||||
return api.MorningStatus(ctx)
|
||||
}),
|
||||
MethodRecentEvents: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]IntakeEvent, error) {
|
||||
out, err := api.RecentEvents(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []IntakeEvent{}
|
||||
}
|
||||
return out, nil
|
||||
MethodRecentEvents: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]IntakeEvent, error) {
|
||||
return api.RecentEvents(ctx, p.N)
|
||||
}),
|
||||
MethodMCPServers: withoutParams(func(ctx context.Context, api CoreAPI) ([]MCPServerStatus, error) {
|
||||
out, err := api.MCPServers(ctx)
|
||||
@@ -654,180 +583,113 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
return marshalResult(PingResp{Alive: true, Locked: locked}), nil
|
||||
|
||||
case MethodAssertStepUp:
|
||||
if s.StepUp != nil {
|
||||
return marshalResult(nil), s.StepUp(ctx)
|
||||
if s.StepUp == nil {
|
||||
return nil, unknownMethod(req.Method)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
return marshalResult(nil), s.StepUp(ctx)
|
||||
|
||||
case MethodStoreEncryptionKey:
|
||||
if s.WrapKeyFn != nil {
|
||||
var p storeEncryptionKeyReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), s.WrapKeyFn(ctx, p.Secret, p.Explicit)
|
||||
if s.WrapKeyFn == nil {
|
||||
return nil, unknownMethod(req.Method)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
var p storeEncryptionKeyReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), s.WrapKeyFn(ctx, p.Secret, p.Explicit)
|
||||
|
||||
case MethodUnlock:
|
||||
if s.UnlockFn != nil {
|
||||
var p unlockReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), s.UnlockFn(ctx, p.Secret)
|
||||
if s.UnlockFn == nil {
|
||||
return nil, unknownMethod(req.Method)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
var p unlockReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), s.UnlockFn(ctx, p.Secret)
|
||||
|
||||
case MethodIngestMail:
|
||||
if s.IngestMailFn != nil {
|
||||
var p IngestMailReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.IngestMailFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
return callDirect(ctx, req, s.IngestMailFn)
|
||||
case MethodSwapModel:
|
||||
if s.SwapModelFn != nil {
|
||||
var p SwapModelReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.SwapModelFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
return callDirect(ctx, req, s.SwapModelFn)
|
||||
case MethodDescribeImage:
|
||||
if s.DescribeImageFn != nil {
|
||||
var p DescribeImageReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.DescribeImageFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
return callDirect(ctx, req, s.DescribeImageFn)
|
||||
case MethodCaptureStart:
|
||||
if s.CaptureStartFn != nil {
|
||||
var p CaptureStartReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.CaptureStartFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
return callDirect(ctx, req, s.CaptureStartFn)
|
||||
case MethodCaptureAppend:
|
||||
if s.CaptureAppendFn != nil {
|
||||
var p CaptureAppendReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.CaptureAppendFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
return callDirect(ctx, req, s.CaptureAppendFn)
|
||||
case MethodCaptureStop:
|
||||
if s.CaptureStopFn != nil {
|
||||
var p CaptureStopReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.CaptureStopFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodCaptureStatus:
|
||||
if s.CaptureStatusFn != nil {
|
||||
resp, err := s.CaptureStatusFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
return callDirect(ctx, req, s.CaptureStopFn)
|
||||
case MethodEnrollSpeaker:
|
||||
if s.EnrollSpeakerFn != nil {
|
||||
var p EnrollSpeakerReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.EnrollSpeakerFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
return callDirect(ctx, req, s.EnrollSpeakerFn)
|
||||
case MethodCaptureStatus:
|
||||
return callDirectNoParams(ctx, req, s.CaptureStatusFn)
|
||||
case MethodListSpeakers:
|
||||
if s.ListSpeakersFn != nil {
|
||||
resp, err := s.ListSpeakersFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodForgetSpeaker:
|
||||
if s.ForgetSpeakerFn != nil {
|
||||
var p ForgetSpeakerReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ForgetSpeakerFn(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
return callDirectNoParams(ctx, req, s.ListSpeakersFn)
|
||||
case MethodModelStatus:
|
||||
if s.ModelStatusFn != nil {
|
||||
resp, err := s.ModelStatusFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
return callDirectNoParams(ctx, req, s.ModelStatusFn)
|
||||
case MethodForgetSpeaker:
|
||||
return callDirectVoid(ctx, req, s.ForgetSpeakerFn)
|
||||
}
|
||||
|
||||
h, ok := methodTable[req.Method]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
return nil, unknownMethod(req.Method)
|
||||
}
|
||||
return h(ctx, api, req.Params)
|
||||
}
|
||||
|
||||
// callDirect runs a daemon-supplied handler that bypasses CoreAPI: unmarshal
|
||||
// the params, call it, marshal the reply. A nil handler is the capability being
|
||||
// unconfigured on this box, and the wire says so as an unknown method.
|
||||
func callDirect[P any, R any](ctx context.Context, req Request, fn func(context.Context, P) (R, error)) (json.RawMessage, error) {
|
||||
if fn == nil {
|
||||
return nil, unknownMethod(req.Method)
|
||||
}
|
||||
var p P
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := fn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(r), nil
|
||||
}
|
||||
|
||||
// callDirectNoParams is callDirect for a handler that reads no params. Like
|
||||
// withoutParams it never touches req.Params.
|
||||
func callDirectNoParams[R any](ctx context.Context, req Request, fn func(context.Context) (R, error)) (json.RawMessage, error) {
|
||||
if fn == nil {
|
||||
return nil, unknownMethod(req.Method)
|
||||
}
|
||||
r, err := fn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(r), nil
|
||||
}
|
||||
|
||||
// callDirectVoid is callDirect for a handler with nothing to report back. The
|
||||
// wire reply is always null.
|
||||
func callDirectVoid[P any](ctx context.Context, req Request, fn func(context.Context, P) error) (json.RawMessage, error) {
|
||||
if fn == nil {
|
||||
return nil, unknownMethod(req.Method)
|
||||
}
|
||||
var p P
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fn(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), nil
|
||||
}
|
||||
|
||||
func unknownMethod(m Method) error {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownMethod, m)
|
||||
}
|
||||
|
||||
func unmarshalParams(raw json.RawMessage, v any) error {
|
||||
if len(raw) == 0 {
|
||||
raw = []byte("null")
|
||||
|
||||
+34
-105
@@ -77,18 +77,24 @@ func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) er
|
||||
return mapErr(a.s.MarkReminder(ctx, id, status))
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
|
||||
rs, err := a.s.ListReminders(ctx, n)
|
||||
// mapRows carries a store read's error through mapErr and converts the rows to
|
||||
// their wire shape. Every list method here is that one shape.
|
||||
func mapRows[S any, W any](rows []S, err error, conv func(S) W) ([]W, error) {
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Reminder, len(rs))
|
||||
for i, r := range rs {
|
||||
out[i] = toReminder(r)
|
||||
out := make([]W, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = conv(r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
|
||||
rs, err := a.s.ListReminders(ctx, n)
|
||||
return mapRows(rs, err, toReminder)
|
||||
}
|
||||
|
||||
func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
|
||||
return mapErr(a.s.RescheduleReminder(ctx, id, now))
|
||||
}
|
||||
@@ -109,85 +115,48 @@ func (a *storeAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]st
|
||||
|
||||
func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||
fs, err := a.s.RecentFacts(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Fact, len(fs))
|
||||
for i, f := range fs {
|
||||
out[i] = toFact(f)
|
||||
}
|
||||
return out, nil
|
||||
return mapRows(fs, err, toFact)
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
|
||||
fs, err := a.s.RecentActiveFactsByKind(ctx, store.FactKind(kind), n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Fact, len(fs))
|
||||
for i, f := range fs {
|
||||
out[i] = toFact(f)
|
||||
}
|
||||
return out, nil
|
||||
return mapRows(fs, err, toFact)
|
||||
}
|
||||
|
||||
func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||
fs, err := a.s.CalendarEvents(ctx, from, to)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Fact, len(fs))
|
||||
for i, f := range fs {
|
||||
out[i] = toFact(f)
|
||||
}
|
||||
return out, nil
|
||||
return mapRows(fs, err, toFact)
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
|
||||
trs, err := a.s.RecentEcosystemTraces(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]EcosystemTrace, len(trs))
|
||||
for i, tr := range trs {
|
||||
out[i] = EcosystemTrace{
|
||||
return mapRows(trs, err, func(tr store.EcosystemTrace) EcosystemTrace {
|
||||
return EcosystemTrace{
|
||||
ID: tr.ID, Ts: tr.Ts, Service: tr.Service, Operation: tr.Operation,
|
||||
Status: tr.Status, DurationMs: tr.DurationMs, CorrelationID: tr.CorrelationID,
|
||||
CausationID: tr.CausationID, HTTPStatus: tr.HTTPStatus, Fields: tr.Fields,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||
ns, err := a.s.RecentNudges(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Nudge, len(ns))
|
||||
for i, ng := range ns {
|
||||
out[i] = toNudge(ng)
|
||||
}
|
||||
return out, nil
|
||||
return mapRows(ns, err, toNudge)
|
||||
}
|
||||
|
||||
func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
|
||||
as, err := a.s.ListDeliveryAttempts(ctx, status, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]DeliveryAttempt, len(as))
|
||||
for i, at := range as {
|
||||
out[i] = DeliveryAttempt{
|
||||
return mapRows(as, err, func(at store.DeliveryAttempt) DeliveryAttempt {
|
||||
out := DeliveryAttempt{
|
||||
ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID,
|
||||
Channel: at.Channel, Status: at.Status, Created: at.Created,
|
||||
}
|
||||
if at.HasComplete {
|
||||
t := at.Completed
|
||||
out[i].Completed = &t
|
||||
out.Completed = &t
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
@@ -197,38 +166,17 @@ func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, emb
|
||||
|
||||
func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
|
||||
ns, err := a.s.QueryNotes(ctx, embedding, k)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Note, len(ns))
|
||||
for i, n := range ns {
|
||||
out[i] = toNote(n)
|
||||
}
|
||||
return out, nil
|
||||
return mapRows(ns, err, toNote)
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
|
||||
ns, err := a.s.RecentNotesFromSource(ctx, prefix, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Note, len(ns))
|
||||
for i, note := range ns {
|
||||
out[i] = toNote(note)
|
||||
}
|
||||
return out, nil
|
||||
return mapRows(ns, err, toNote)
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
||||
ns, err := a.s.RecentNotes(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Note, len(ns))
|
||||
for i, note := range ns {
|
||||
out[i] = toNote(note)
|
||||
}
|
||||
return out, nil
|
||||
return mapRows(ns, err, toNote)
|
||||
}
|
||||
|
||||
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
||||
@@ -299,14 +247,7 @@ func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
|
||||
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
||||
ts, err := a.s.ListTools(ctx, status)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Tool, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = toTool(t)
|
||||
}
|
||||
return out, nil
|
||||
return mapRows(ts, err, toTool)
|
||||
}
|
||||
|
||||
func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
@@ -334,12 +275,8 @@ func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (Capture
|
||||
|
||||
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
ts, err := a.s.ListTasks(ctx, status)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Task, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = Task{
|
||||
return mapRows(ts, err, func(t store.Task) Task {
|
||||
return Task{
|
||||
ID: t.ID,
|
||||
CreatedTs: t.CreatedTs,
|
||||
Text: t.Text,
|
||||
@@ -354,8 +291,7 @@ func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||
DoneWhen: t.DoneWhen,
|
||||
BlockedOn: t.BlockedOn,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
||||
@@ -379,24 +315,17 @@ func (a *storeAPI) SetTaskFields(ctx context.Context, id int64, doneWhen, blocke
|
||||
|
||||
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
rs, err := a.s.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]ProposedRoutine, len(rs))
|
||||
for i, r := range rs {
|
||||
out[i] = ProposedRoutine{
|
||||
return mapRows(rs, err, func(r store.ProposedRoutine) ProposedRoutine {
|
||||
return ProposedRoutine{
|
||||
ID: r.ID,
|
||||
Action: r.Action,
|
||||
Object: r.Object,
|
||||
IntervalDays: r.IntervalDays,
|
||||
Status: string(r.Status),
|
||||
CreatedTs: r.CreatedTs.UnixMilli(),
|
||||
ReminderID: r.ReminderID,
|
||||
}
|
||||
if r.ReminderID != nil {
|
||||
out[i].ReminderID = r.ReminderID
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
|
||||
+41
-891
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
// phraser/nudge_llm.go — what the model is told about one nudge, and what she
|
||||
// says when it gives back nothing usable.
|
||||
//
|
||||
// The default nudge path is not this one: hand-written templates word every
|
||||
// nudge unless Config.LLMNudges is on. See nudge_templates.go, and the note on
|
||||
// that field for why.
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
)
|
||||
|
||||
// ruleTopics — Russian gloss for each built-in rule name. The rule names are
|
||||
// English identifiers; a 0.8B asked to nudge about "netdata_critical" writes
|
||||
// about nothing. The daemon knows what its own rules mean, so it says so.
|
||||
var ruleTopics = map[string]string{
|
||||
"water": "он давно не пил воду",
|
||||
"meal": "он давно не ел",
|
||||
"break": "он давно без перерыва, пора встать и размяться",
|
||||
"service_down": "сервис не отвечает, лежит",
|
||||
"netdata_critical": "критический алярм в netdata, проблема с диском или местом",
|
||||
}
|
||||
|
||||
// ruleKeywords — the word the message must contain. The 0.8B drifts to
|
||||
// whatever topic it saw last unless the required word is named outright.
|
||||
var ruleKeywords = map[string]string{
|
||||
"water": "воду",
|
||||
"meal": "поешь",
|
||||
"break": "перерыв",
|
||||
"service_down": "сервис",
|
||||
"netdata_critical": "диск",
|
||||
}
|
||||
|
||||
// ruleTopic turns a rule name into a Russian description of the situation.
|
||||
// "routine:зарядка" and "morning:утро" carry their own Russian suffix.
|
||||
func ruleTopic(rule string) string {
|
||||
if t, ok := ruleTopics[rule]; ok {
|
||||
return t
|
||||
}
|
||||
if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) {
|
||||
switch rule[:i] {
|
||||
case "morning":
|
||||
return "утро, пора начать день: " + rule[i+1:]
|
||||
default:
|
||||
return "пора сделать по распорядку: " + rule[i+1:]
|
||||
}
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
// ruleKeyword — the word the nudge must contain, or "" when the rule name's
|
||||
// own Russian suffix already is that word.
|
||||
func ruleKeyword(rule string) string {
|
||||
if k, ok := ruleKeywords[rule]; ok {
|
||||
return k
|
||||
}
|
||||
if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) {
|
||||
return rule[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ruDur — duration in Russian. humanDur is English and its output was landing
|
||||
// verbatim in the message.
|
||||
func ruDur(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
h, m := int(d.Hours()), int(d.Minutes())%60
|
||||
switch {
|
||||
case h >= 2:
|
||||
return fmt.Sprintf("%d ч", h)
|
||||
case h == 1 && m >= 30:
|
||||
return "полтора часа"
|
||||
case h == 1:
|
||||
return "час"
|
||||
default:
|
||||
return fmt.Sprintf("%d мин", m)
|
||||
}
|
||||
}
|
||||
|
||||
// fallbackNudge — plain Russian for when the model returns nothing parseable.
|
||||
var fallbackNudges = map[string]string{
|
||||
"water": "Ты давно не пил воду.",
|
||||
"meal": "Ты давно не ел, поешь.",
|
||||
"break": "Пора сделать перерыв.",
|
||||
"service_down": "Сервис не отвечает.",
|
||||
"netdata_critical": "Критический алярм: проверь диск.",
|
||||
}
|
||||
|
||||
func fallbackNudge(c loop.Candidate) string {
|
||||
if down := loop.DownServices(c.State); len(down) > 0 {
|
||||
return "Не отвечает: " + strings.Join(down, ", ") + "."
|
||||
}
|
||||
if s, ok := fallbackNudges[c.Rule.Name]; ok {
|
||||
return s
|
||||
}
|
||||
if kw := ruleKeyword(c.Rule.Name); kw != "" {
|
||||
return "Напоминаю: " + kw + "."
|
||||
}
|
||||
return "Напоминаю о деле."
|
||||
}
|
||||
|
||||
func buildNudgePrompt(c loop.Candidate) string {
|
||||
var ctxParts []string
|
||||
ctxParts = append(ctxParts, "Ситуация: "+ruleTopic(c.Rule.Name))
|
||||
if f, ok := c.State.Facts[c.Rule.Name]; ok && f.Key != "" && f.Key != c.Rule.Name {
|
||||
ctxParts = append(ctxParts, "Что именно: "+f.Key)
|
||||
}
|
||||
if down := loop.DownServices(c.State); len(down) > 0 {
|
||||
// The names come from the same helper the rule fired on, so the model
|
||||
// is never handed a service that is actually up.
|
||||
ctxParts = append(ctxParts, "Какие сервисы лежат: "+strings.Join(down, ", "))
|
||||
}
|
||||
if d, ok := c.State.Since(c.Rule.Name); ok {
|
||||
ctxParts = append(ctxParts, "Прошло: "+ruDur(d))
|
||||
}
|
||||
switch sevLabel(c.Severity) {
|
||||
case "alarm":
|
||||
ctxParts = append(ctxParts, "Срочно, скажи прямо.")
|
||||
case "ops":
|
||||
ctxParts = append(ctxParts, "Это про сервер, не про здоровье.")
|
||||
}
|
||||
tail := "Напиши напоминание про эту ситуацию. Одно предложение, по-русски, в JSON."
|
||||
if kw := ruleKeyword(c.Rule.Name); kw != "" {
|
||||
// Last line on purpose: a 0.8B weights the end of the prompt hardest,
|
||||
// and without the required word it drifts back to the examples.
|
||||
tail += " Ответ ДОЛЖЕН содержать слово «" + kw + "»."
|
||||
}
|
||||
|
||||
return strings.Join(ctxParts, "\n") + "\n\n" + tail
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// phraser/parse.go — the reply-side of the LLM output contract:
|
||||
// {"response":"...","mood":"..."} in, a string and a mood out.
|
||||
//
|
||||
// One parser, and every phrasing path in the repo goes through it — the six
|
||||
// LLMPhraser methods, PhraseWorld, and Replier.PhraseReply, which cmd/mavend
|
||||
// wraps. The contract is written down in CLAUDE.md; this file is the only
|
||||
// place it is implemented, so the two halves cannot drift.
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type responseMood struct {
|
||||
Response string `json:"response"`
|
||||
Mood string `json:"mood"`
|
||||
}
|
||||
|
||||
// errBrokenJSON — the model started a JSON object and never finished it.
|
||||
// That is a failed generation, not a reply. Callers must use their fallback.
|
||||
var errBrokenJSON = fmt.Errorf("phraser: model output starts as JSON but does not parse")
|
||||
|
||||
// parseResponseMood extracts {"response","mood"} from LLM output, tolerant
|
||||
// of thinking tokens and extra text before/after the JSON block.
|
||||
//
|
||||
// Three outcomes:
|
||||
// - parsed fine → the fields, nil error.
|
||||
// - output never looked like JSON → ("", "", nil). The caller may ship it
|
||||
// as-is; small models sometimes answer in bare prose and that is fine.
|
||||
// - output starts with "{" but does not parse → errBrokenJSON. The grammar
|
||||
// guarantees a valid *prefix*, so a generation that hits the token cap
|
||||
// mid-object comes back as a fragment like `{` or `{\n "`. Shipping that
|
||||
// as a reply is the bug this error exists to stop.
|
||||
func parseResponseMood(raw string) (response, mood string, err error) {
|
||||
cleaned := strings.TrimSpace(raw)
|
||||
start := strings.Index(cleaned, "{")
|
||||
end := strings.LastIndex(cleaned, "}")
|
||||
if start < 0 || end < 0 || end <= start {
|
||||
if strings.HasPrefix(cleaned, "{") {
|
||||
return "", "", errBrokenJSON
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
var parsed responseMood
|
||||
if e := json.Unmarshal([]byte(escapeRawControls(cleaned[start:end+1])), &parsed); e != nil {
|
||||
if strings.HasPrefix(cleaned, "{") {
|
||||
return "", "", errBrokenJSON
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
return parsed.Response, parsed.Mood, nil
|
||||
}
|
||||
|
||||
// escapeRawControls escapes the control characters a model writes literally
|
||||
// inside a JSON string, so a reply that is otherwise fine still parses.
|
||||
//
|
||||
// The grammar is what stops these being generated (Vikunja #537). This is the
|
||||
// second line, for the paths that send no grammar at all — NoGrammar, and any
|
||||
// remote model whose server ignores one. A raw newline is the shape that was
|
||||
// measured; the rest of the range is here because the same argument covers it.
|
||||
//
|
||||
// Inside a string only. The first version escaped the whole object on the
|
||||
// argument that JSON permits no control character outside a string either, so
|
||||
// rewriting one could not do harm. That argument is wrong: JSON permits a
|
||||
// newline, a tab and a return BETWEEN tokens, which is what pretty-printing is.
|
||||
// Qwen3-1.7B pretty-prints — it opens `{` and writes three newlines before the
|
||||
// first key — and escaping those into a literal backslash-n broke every reply
|
||||
// it wrote. Measured 2026-08-05 on the talk fixture: 31 of 36 conversational
|
||||
// cases came back as errBrokenJSON and answered from the stub (Vikunja #44).
|
||||
func escapeRawControls(s string) string {
|
||||
if !strings.ContainsFunc(s, func(r rune) bool { return r < 0x20 }) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 8)
|
||||
inString := false
|
||||
escaped := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case escaped:
|
||||
// The character after a backslash is the model's own escape and is
|
||||
// already whatever it meant to write.
|
||||
escaped = false
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
case inString && r == '\\':
|
||||
escaped = true
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
case r == '"':
|
||||
inString = !inString
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case !inString || r >= 0x20:
|
||||
b.WriteRune(r)
|
||||
case r == '\n':
|
||||
b.WriteString(`\n`)
|
||||
case r == '\r':
|
||||
b.WriteString(`\r`)
|
||||
case r == '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
fmt.Fprintf(&b, `\u%04x`, r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// stripThink removes the <think> block that Thinking-variant models emit
|
||||
// before the actual response. No-op when no think block is present.
|
||||
func stripThink(s string) string {
|
||||
if i := strings.LastIndex(s, "</think>"); i >= 0 {
|
||||
s = strings.TrimSpace(s[i+8:])
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// phraser/prompts.go — every system prompt this package sends, and the two
|
||||
// helpers that render a user turn.
|
||||
//
|
||||
// The text is load-bearing and none of it is edited here: llm/check_prompt_parity.py
|
||||
// in the training workspace pins the Go prompts to the relabelling ones, so a
|
||||
// reworded line breaks a contract silently. Each prompt carries the measurement
|
||||
// that produced its shape; read the comment before touching the string.
|
||||
//
|
||||
// One rule runs through all of them. She is feminine about herself (-ла), he is
|
||||
// male and addressed as "ты", and she talks TO him and never about him. See
|
||||
// CheckFeminine, CheckAddress and CheckCringe in eval/checks.go.
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/persona"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// chatSystemPrompt returns the system prompt for conversational chat.
|
||||
// Prepends the shared context block when the phraser has one.
|
||||
func chatSystemPrompt(block func() string) string {
|
||||
// No self-introduction here: the persona block prepended one line above
|
||||
// already says who she is, same as router.KnowledgePrompt.
|
||||
//
|
||||
// The grammar examples used to be full clauses: ("я подумала", "я рада")
|
||||
// for her, ("ты сказал", "ты забыл") for him. A 1.7B copies those rather
|
||||
// than generalising from them. Observed on the box 2026-08-01: all three
|
||||
// chat replies in one session opened with "Я подумала, что ...", and one
|
||||
// ended "...немного тревожусь. ты сказал" — the second example pasted onto
|
||||
// the end of a finished sentence, which reads as a truncation but is not.
|
||||
//
|
||||
// So: contrastive pairs instead of usable openers. "рада, не рад" states
|
||||
// the rule as a correction, and short predicatives do not hand the model a
|
||||
// sentence frame to start with. The him-examples are gone entirely; the
|
||||
// "ты" instruction carries that on its own and those two produced the
|
||||
// worst output. The last line says outright not to echo the instructions,
|
||||
// because a small model will otherwise treat any quoted string as licence.
|
||||
//
|
||||
// Amended the same day: with the openers gone the tic went with them, but
|
||||
// "не забыл ли я" appeared — masculine, about herself. The old "я подумала"
|
||||
// had been suppressing that by accident, being a feminine past tense the
|
||||
// model could copy. Two short predicatives are not enough signal on their
|
||||
// own, so the rule is now stated as morphology (-ла) rather than as a pair
|
||||
// of words. A suffix rule generalises where an example only gets copied.
|
||||
base := `Ты разговариваешь с хозяином.
|
||||
|
||||
О себе — в женском роде: "рада", не "рад"; "поняла", не "понял". Все свои глаголы в прошедшем времени оканчивай на -ла: сделала, забыла, записала, подумала. Он мужчина: обращайся к нему на "ты", в мужском роде. Никогда не "вы"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём.
|
||||
|
||||
Отвечай по-русски, коротко: одна-три фразы, живым языком. Ты доброжелательная, тебе интересно, но чувства не изображай. Не повторяй формулировки из этой инструкции — отвечай своими словами.
|
||||
|
||||
Отвечай ТОЛЬКО одним объектом JSON: {"response": "...", "mood": "neutral"}. В "response" — твой ответ. В "mood" — ровно одно из: neutral, happy, thinking, tired, confused.`
|
||||
return persona.Prepend(block, base)
|
||||
}
|
||||
|
||||
// nudgeSystem — the phrasing contract for nudges.
|
||||
//
|
||||
// Written as filled-in examples, not as a schema with "..." in it. A 0.8B
|
||||
// copies whatever sits in the response slot, so a literal placeholder there
|
||||
// teaches it to answer with the placeholder. Measured: 7/15 nudges came back
|
||||
// as "..." before this. See docs/evals/2026-07-31-phrasing.md.
|
||||
//
|
||||
// Russian only, feminine self-reference, second person masculine (the owner is
|
||||
// a man). She talks TO him, informally, singular — never "вы", never "он".
|
||||
// One short sentence — the nudge is spoken aloud.
|
||||
//
|
||||
// What the ban on обращения forbids is pet names ("дорогой", "милый"), not his
|
||||
// name: "Ками, ноутбук на трёх процентах" is exactly how she talks, and the
|
||||
// unqualified word read as forbidding that too. Hence "ласковые обращения".
|
||||
//
|
||||
// The examples also never claim a physical act. She has no hands and no smart
|
||||
// plug — she can tell him the battery is at three percent, she cannot put the
|
||||
// laptop on charge. An example that says she did teaches the model to invent
|
||||
// actions Maven never took, which is worse than a missing nudge.
|
||||
const nudgeSystem = `Ты — Maven, домашняя ассистентка. О себе говоришь в женском роде ("я проверила", "я записала"). Владелец — мужчина, обращайся к нему в мужском роде ("ты пил", "ты забыл").
|
||||
Говоришь с ним на "ты", в единственном числе ("выпей", "встань"). Никогда не "вы"/"вас"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём.
|
||||
|
||||
Пиши ОДНО короткое напоминание по-русски: не больше 120 символов и не больше 16 слов. Только по делу.
|
||||
|
||||
Запрещено: ласковые обращения ("дорогой", "милый"), эмодзи, извинения ("прости", "извини"), вопросы о самочувствии, похвала, больше одного восклицательного знака, английские слова кроме имён сервисов.
|
||||
|
||||
Отвечай ТОЛЬКО одним объектом JSON с полями "response" и "mood".
|
||||
"response" — сам текст напоминания.
|
||||
"mood" — ровно одно из: neutral, happy, thinking, tired, confused.
|
||||
|
||||
Так выглядит правильный ответ по форме. Темы здесь посторонние — их в запросе не будет:
|
||||
{"response": "Стиральная машина закончила. Развесь бельё.", "mood": "neutral"}
|
||||
{"response": "Ками, ноутбук на трёх процентах. Поставь его на зарядку.", "mood": "confused"}
|
||||
|
||||
Это примеры ФОРМЫ, а не темы. Пиши только про ту ситуацию, которую тебе дали в запросе. Не копируй примеры и никогда не пиши "..." в поле response.`
|
||||
|
||||
func (p *LLMPhraser) systemPrompt() string {
|
||||
return persona.Prepend(p.cfg.ContextBlock, nudgeSystem)
|
||||
}
|
||||
|
||||
// knowledgePrompt — the no-sources branch: a world question, answered from
|
||||
// weights alone. The system prompt is the single tested source in
|
||||
// router.KnowledgePrompt.
|
||||
//
|
||||
// Split out of PhraseQuery so PhraseWorld sends the workstation model the same
|
||||
// bytes the resident model gets. Prompt parity across two models is a stated
|
||||
// constraint (CLAUDE.md), and two copies of a prompt is how it stops holding.
|
||||
func (p *LLMPhraser) knowledgePrompt(utterance string) (sys, user string) {
|
||||
return persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt()),
|
||||
fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance)
|
||||
}
|
||||
|
||||
// selfPrompt — the one subject she does not have to read about. Same
|
||||
// discipline as the evidence branch, say only what the text says, and a
|
||||
// different opener: "вот что я нашла: я — твоя помощница" says she looked
|
||||
// herself up (Vikunja #555), and she did not.
|
||||
func (p *LLMPhraser) selfPrompt(utterance, description string) (sys, user string) {
|
||||
sys = persona.Prepend(p.cfg.ContextBlock,
|
||||
"Он спрашивает о тебе. Отвечай ТОЛЬКО по описанию, которое тебе дали: всё, что ты говоришь о себе, должно быть в нём. "+
|
||||
"Не добавляй умений, которых там нет, и не догадывайся. Не начинай с \"вот что я нашла\" — ты говоришь о себе, а не о находке. "+
|
||||
// The gender rule is stated WITHOUT the "-ла" example the other
|
||||
// prompts carry. Measured on the box: a 1.7B reads that as an
|
||||
// instruction to use the past tense and answers "я вела заметки,
|
||||
// управляла домом" — she describes what she does, in the present,
|
||||
// and the past tense makes a live capability sound finished.
|
||||
"Отвечай по-русски, коротко и своими словами, в настоящем времени — ты описываешь, что делаешь сейчас. О себе говори в женском роде. "+
|
||||
"Он мужчина, обращайся к нему на \"ты\". Отвечай ТОЛЬКО одним объектом JSON: {\"response\": \"...\", \"mood\": \"neutral\"}.")
|
||||
return sys, fmt.Sprintf("Он спрашивает: %q\n\nТвоё описание:\n%s\n\nОтветь ему на то, что он спросил.", utterance, description)
|
||||
}
|
||||
|
||||
// evidencePrompt — the sources branch: read these, add nothing. Shared with
|
||||
// PhraseWorld for the same reason as knowledgePrompt.
|
||||
func (p *LLMPhraser) evidencePrompt(utterance string, notes []string) (sys, user string) {
|
||||
return p.querySystemPrompt(), fmt.Sprintf(
|
||||
"Он спрашивает: \"%s\"\n\nИсточники:\n%s\nОтветь ему коротко и своими словами, опираясь только на эти источники. Если ответа в них нет — так и скажи.",
|
||||
utterance, evidenceBlock(notes),
|
||||
)
|
||||
}
|
||||
|
||||
// querySystemPrompt returns the system prompt for the evidence branch of
|
||||
// PhraseQuery. Prepends the configured persona when set.
|
||||
//
|
||||
// Evidence-first, and that is the whole point of this prompt. Every source that
|
||||
// reaches PhraseQuery with something in hand — his notes, a stored fact, a page,
|
||||
// a live search, a ZIM article — arrives as numbered sources, and the model's
|
||||
// job here is to READ them, not to recall. A 1.7B asked a world question
|
||||
// answers from its weights with total confidence and no signal that it is
|
||||
// guessing; that is how "Война и мир" got Левитан as its author. The rule that
|
||||
// prevents it is stated three ways, because one way did not hold: answer from
|
||||
// the sources, say plainly when they do not answer, add nothing of your own.
|
||||
//
|
||||
// It no longer says "заметки". The sources are not always his notes, and
|
||||
// calling a Wikipedia paragraph his note both misleads him and licenses the
|
||||
// model to blur where an answer came from.
|
||||
//
|
||||
// No self-introduction here: the persona block prepended one line above already
|
||||
// says who she is, same as router.KnowledgePrompt.
|
||||
//
|
||||
// The opener is deliberate and stays: the fixed prefix is what marks the answer
|
||||
// as a lookup rather than as something she knows. The grammar examples are not
|
||||
// deliberate — same defect chatSystemPrompt had, where a 1.7B copies a quoted
|
||||
// word instead of generalising from it. Stated as morphology instead.
|
||||
func (p *LLMPhraser) querySystemPrompt() string {
|
||||
base := "Ты отвечаешь ему по источникам, которые тебе дали. Отвечай ТОЛЬКО по ним: всё, что ты говоришь, должно быть написано в источниках. " +
|
||||
"Если ответа в них нет — так и скажи и на этом остановись; не добавляй ничего из своих знаний и не догадывайся. " +
|
||||
"Не приплетай прошлые реплики разговора. " +
|
||||
"Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде, глаголы в прошедшем времени с окончанием -ла. Он мужчина, обращайся к нему на \"ты\". Отвечай ТОЛЬКО одним объектом JSON: {\"response\": \"...\", \"mood\": \"neutral\"}."
|
||||
return persona.Prepend(p.cfg.ContextBlock, base)
|
||||
}
|
||||
|
||||
// evidenceBlock renders the sources for the evidence branch of PhraseQuery.
|
||||
//
|
||||
// Numbered lines, one source each, rather than the quoted semicolon-joined
|
||||
// string this used to build. Two reasons, both measured on small models: a
|
||||
// numbered list survives being long, where a run-on quoted string blurs into
|
||||
// one claim the model then merges; and the numbering gives it something to
|
||||
// answer FROM, which is what makes "этого в источниках нет" reachable at all.
|
||||
func evidenceBlock(sources []string) string {
|
||||
var b strings.Builder
|
||||
for i, s := range sources {
|
||||
fmt.Fprintf(&b, "[%d] %s\n", i+1, s)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// nonEmpty drops blank sources and trims the rest, without touching the
|
||||
// caller's slice.
|
||||
func nonEmpty(sources []string) []string {
|
||||
out := make([]string, 0, len(sources))
|
||||
for _, s := range sources {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// phraser/server.go — the llama-server this phraser talks to: the child
|
||||
// process it may own, and the startup handshake that waits for the port.
|
||||
//
|
||||
// Nothing here knows what a prompt is. Split out of llmphraser.go so the
|
||||
// phrasing paths and the process lifetime read as two subjects.
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
|
||||
|
||||
// backend — one llama-server this phraser talks to. Two implementations: a
|
||||
// llamaProc we spawned and must reap, and a borrowedBackend someone else owns.
|
||||
type backend interface {
|
||||
BaseURL() string
|
||||
Close() error
|
||||
}
|
||||
|
||||
// borrowedBackend — a server started and owned by someone else (the phrasing
|
||||
// scorer's shared llama-server). Closing it is a no-op by construction.
|
||||
type borrowedBackend string
|
||||
|
||||
func (b borrowedBackend) BaseURL() string { return string(b) }
|
||||
func (b borrowedBackend) Close() error { return nil }
|
||||
|
||||
// llamaProc — a llama-server child process plus the goroutine reading its
|
||||
// stderr. Close kills and reaps it; see the Pdeathsig note in spawnLlamaServer.
|
||||
type llamaProc struct {
|
||||
base string
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func (l *llamaProc) BaseURL() string { return l.base }
|
||||
|
||||
func (l *llamaProc) Close() error {
|
||||
l.cancel()
|
||||
if l.cmd != nil && l.cmd.Process != nil {
|
||||
_ = l.cmd.Process.Kill()
|
||||
_ = l.cmd.Wait() // reap the process — without Wait, the child becomes a zombie
|
||||
}
|
||||
l.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// lineTail keeps the last few startup lines so a server that dies before it
|
||||
// listens can say why in the error, not just "EOF". Written by the reader
|
||||
// goroutine and read by whoever gives up on startup, so it takes a lock.
|
||||
type lineTail struct {
|
||||
mu sync.Mutex
|
||||
lines []string
|
||||
}
|
||||
|
||||
const lineTailMax = 12
|
||||
|
||||
func (t *lineTail) add(line string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.lines = append(t.lines, line)
|
||||
if len(t.lines) > lineTailMax {
|
||||
t.lines = t.lines[len(t.lines)-lineTailMax:]
|
||||
}
|
||||
}
|
||||
|
||||
func (t *lineTail) String() string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if len(t.lines) == 0 {
|
||||
return "(no output)"
|
||||
}
|
||||
return strings.Join(t.lines, " | ")
|
||||
}
|
||||
|
||||
func extractPort(listen string) string {
|
||||
_, port, _ := strings.Cut(listen, ":")
|
||||
if port == "" {
|
||||
return "0"
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
// spawnLlamaServer starts one llama-server for cfg and waits until it says which
|
||||
// address it is listening on. ctx owns the process lifetime, so it must be the
|
||||
// daemon's context, not a request's.
|
||||
func spawnLlamaServer(ctx context.Context, cfg Config) (backend, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
p, err := startLlamaProc(ctx, cfg)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
p.cancel = cancel
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// llamaArgs is the command line for one resident server. It is a function and
|
||||
// not an inline literal because kill-maven.sh's orphan sweep matches against
|
||||
// this exact line, and a test pins the two together.
|
||||
func llamaArgs(cfg Config) []string {
|
||||
args := []string{
|
||||
"-m", cfg.ModelPath,
|
||||
"--host", "127.0.0.1",
|
||||
"--port", extractPort(cfg.Listen),
|
||||
"-c", fmt.Sprintf("%d", cfg.NCtx),
|
||||
"-ngl", fmt.Sprintf("%d", cfg.NGpuLayers),
|
||||
"--no-webui",
|
||||
}
|
||||
if cfg.CacheRAMMiB > 0 {
|
||||
args = append(args, "--cache-ram", fmt.Sprintf("%d", cfg.CacheRAMMiB))
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// defaultStartupTimeout — the wait for llama-server's listen line when Config
|
||||
// does not set one. A cold model load off disk is the slow part.
|
||||
const defaultStartupTimeout = 60 * time.Second
|
||||
|
||||
func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) {
|
||||
startupTimeout := cfg.StartupTimeout
|
||||
if startupTimeout <= 0 {
|
||||
startupTimeout = defaultStartupTimeout
|
||||
}
|
||||
p := &llamaProc{}
|
||||
cmd := exec.CommandContext(ctx, cfg.BinPath, llamaArgs(cfg)...)
|
||||
// Pdeathsig: the kernel SIGKILLs llama-server the moment mavend dies — by
|
||||
// ANY means, including SIGKILL/OOM/panic where our Close() never runs. Without
|
||||
// it a hard-killed mavend orphans its llama-server (reparented to init, keeps
|
||||
// eating GPU/RAM); repeated dev restarts pile up orphans until the box OOMs.
|
||||
// Setpgid isolates it in its own process group so a stray Ctrl-C on the
|
||||
// terminal group doesn't half-kill it out from under us. (Linux-only, like
|
||||
// the rest of the daemon.)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL}
|
||||
p.cmd = cmd
|
||||
|
||||
// One pipe for both streams. llama.cpp writes its buffer sizes, KV-cache
|
||||
// layout and offload lines to stderr and its request log to stdout, and
|
||||
// stdout used to go nowhere at all — so nothing about the model's memory was
|
||||
// diagnosable from a running box. Both ends land in mavend's log now.
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: output pipe: %w", err)
|
||||
}
|
||||
cmd.Stdout = pw
|
||||
cmd.Stderr = pw
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
pr.Close()
|
||||
pw.Close()
|
||||
return nil, fmt.Errorf("llm: start: %w", err)
|
||||
}
|
||||
// The child holds the only other reference to the write end. Dropping ours
|
||||
// is what makes the reader see EOF when the child dies.
|
||||
pw.Close()
|
||||
|
||||
portCh := make(chan string, 1)
|
||||
errCh := make(chan error, 1)
|
||||
tail := &lineTail{}
|
||||
p.wg.Add(1)
|
||||
go func() {
|
||||
defer p.wg.Done()
|
||||
defer pr.Close()
|
||||
sc := bufio.NewScanner(pr)
|
||||
// llama.cpp prints one prompt per line and a prompt can be long.
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
listening := false
|
||||
for sc.Scan() {
|
||||
line := sc.Bytes()
|
||||
log.Printf("llama: %s", line)
|
||||
if !listening {
|
||||
tail.add(string(line))
|
||||
if m := listenRE.FindSubmatch(line); len(m) > 1 {
|
||||
listening = true
|
||||
portCh <- string(m[1])
|
||||
close(portCh)
|
||||
}
|
||||
}
|
||||
}
|
||||
err := sc.Err()
|
||||
if err == nil {
|
||||
err = io.EOF
|
||||
}
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
fail := func(err error) (*llamaProc, error) {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case addr := <-portCh:
|
||||
p.base = addr
|
||||
return p, nil
|
||||
case err := <-errCh:
|
||||
// The tail is the whole diagnosis when the server dies during load: bare
|
||||
// "EOF" never said which layer or which allocation it choked on.
|
||||
return fail(fmt.Errorf("llm: server output: %w; last output: %s", err, tail.String()))
|
||||
case <-ctx.Done():
|
||||
return fail(ctx.Err())
|
||||
case <-time.After(startupTimeout):
|
||||
return fail(fmt.Errorf("llm: server did not start within %s; last output: %s", startupTimeout, tail.String()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// phraser/transport.go — the wire to one llama-server: the request and
|
||||
// response shapes, the GBNF that binds the model to the reply contract, and
|
||||
// the single POST every phrasing path in this package goes through.
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type chatMsg struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatReq struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMsg `json:"messages"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
// Grammar is llama-server's `grammar` field (GBNF). Same wiring as
|
||||
// internal/llm.Req.Grammar. Empty ⇒ unconstrained sampling.
|
||||
Grammar string `json:"grammar,omitempty"`
|
||||
// RepeatPenalty — defence in depth behind the bounded grammar, not the fix
|
||||
// for #531. This struct had no such field, so every caller through
|
||||
// chatWithSystem ran at the server default of 1.0 while Replier.PhraseReply
|
||||
// sent 1.3 through internal/llm and was protected by accident. Two wire
|
||||
// structs that disagree about the sampler is the condition that let one
|
||||
// path run away and the other not, and it should not survive as a
|
||||
// difference nobody chose.
|
||||
RepeatPenalty float64 `json:"repeat_penalty,omitempty"`
|
||||
}
|
||||
|
||||
// phraseRepeatPenalty — matches Replier.PhraseReply, which has sent 1.3 since
|
||||
// it was written. The value is not tuned here and is not what stops the
|
||||
// whitespace loop; the bounded ws rule is. It is here so the two phrasing
|
||||
// paths sample alike.
|
||||
const phraseRepeatPenalty = 1.3
|
||||
|
||||
// responseGrammar — GBNF constraining the model to the documented phrasing
|
||||
// contract and nothing else: {"response": "<text>", "mood": "<enum>"}.
|
||||
//
|
||||
// Without it a 0.8B answers roughly one chat turn in three with open reasoning
|
||||
// as plain text ("Thinking Process:" …), which no tag-stripper can remove and
|
||||
// which eats the token budget before the JSON closes. Modelled on
|
||||
// routeGrammar in internal/router/llmrouter.go so the two read alike.
|
||||
//
|
||||
// text accepts ANY codepoint except the two JSON must escape and the control
|
||||
// range — the replies are Russian, so an ASCII-only rule would make every reply
|
||||
// empty. The escape rule is what lets the model close a string it opened with a
|
||||
// quote inside. Length is bounded so a repetition loop truncates the field, not
|
||||
// the JSON object.
|
||||
//
|
||||
// The control range is excluded because a raw newline inside a JSON string is
|
||||
// not JSON (Vikunja #537). The class used to be `[^"\\]`, which let the model
|
||||
// write a multi-line reply that satisfied the grammar and then failed
|
||||
// json.Unmarshal with "invalid character '\n' in string literal" — the object
|
||||
// starts with "{", so it came back as errBrokenJSON and the case answered with
|
||||
// an empty string. Sixty of the failures in the 2026-08-05 temperature sweep
|
||||
// were that one error, and it never once hit the token cap, which is why the
|
||||
// truncation reading was wrong. The escape alternatives are llama.cpp's own
|
||||
// json.gbnf: a model that wants a line break must write \n, which parses.
|
||||
//
|
||||
// That bound was 400 and 400 was too tight. Measured against Qwen3.5-0.8B: on
|
||||
// "почему гром слышно позже молнии?" the reply came back exactly 400 characters
|
||||
// long, cut mid-word ("Нужно записать и,"), at every token cap from 256 to 2048.
|
||||
// So the token cap was never what stopped it — this rule was. 1000 characters is
|
||||
// roughly six Russian sentences, still short enough to stop a repetition loop.
|
||||
//
|
||||
// ws is bounded for the same reason and it is the more expensive of the two.
|
||||
// `*` let the model open the object and then satisfy ws with whitespace until
|
||||
// max_tokens, which is 512 here: both interactive turns measured on 2026-08-04
|
||||
// decoded exactly 512 tokens and spent 24-30 seconds doing it, all of it
|
||||
// whitespace (Vikunja #531). Nothing on this path sends a repeat penalty —
|
||||
// chatReq had no field for one — so the sampler never broke the loop. {0,4}
|
||||
// was measured: three runs, three clean stops at 33 tokens, no penalty needed.
|
||||
const responseGrammar = `
|
||||
root ::= "{" ws "\"response\"" ws ":" ws string ws "," ws "\"mood\"" ws ":" ws mood ws "}"
|
||||
mood ::= "\"neutral\"" | "\"happy\"" | "\"thinking\"" | "\"tired\"" | "\"confused\""
|
||||
string ::= "\"" ([^"\\\x00-\x1F] | "\\" ["\\/bfnrt] | "\\u" [0-9a-fA-F]{4}){0,1000} "\""
|
||||
ws ::= [ \t\n]{0,4}
|
||||
`
|
||||
|
||||
// ResponseGrammar exposes responseGrammar to the other callers that emit the
|
||||
// same {"response","mood"} contract — cmd/mavend's reactive replier, which is
|
||||
// parsed by the same two fields. One definition, so the two cannot drift.
|
||||
const ResponseGrammar = responseGrammar
|
||||
|
||||
// grammar returns the GBNF to attach to a phrasing request, or "" when the
|
||||
// operator turned it off.
|
||||
func (p *LLMPhraser) grammar() string {
|
||||
if p.cfg.NoGrammar {
|
||||
return ""
|
||||
}
|
||||
return responseGrammar
|
||||
}
|
||||
|
||||
type chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
} `json:"message"`
|
||||
// FinishReason — "stop" when the model chose to end, "length" when the
|
||||
// token cap cut it off. Parsed since #531, where two turns ran to the
|
||||
// 512-token cap and both happened to parse anyway: the grammar had
|
||||
// already closed the JSON, so a truncated generation was indistinguishable
|
||||
// from a good one at every layer above this struct.
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// logIfTruncated says so when a generation stopped at the token cap.
|
||||
//
|
||||
// A cap hit is never routine. Either the model was looping, which is the #531
|
||||
// shape, or the reply was genuinely longer than maxTokens, which means she cut
|
||||
// herself off mid-sentence. Both are worth a line, and neither produced one
|
||||
// before: the caller sees a parsed string and cannot tell.
|
||||
func logIfTruncated(where, reason string, maxTokens int) {
|
||||
if reason == "length" {
|
||||
log.Printf("phraser: %s hit the %d-token cap (finish_reason=length) — the reply is truncated, or the model was looping", where, maxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// postChat sends one message array to the resident llama-server and returns
|
||||
// what the model wrote, think block stripped.
|
||||
//
|
||||
// The only POST in the package. chatWithSystem and chatWithMessages each
|
||||
// carried their own copy of these forty lines, identical down to the error
|
||||
// strings, and the copies had already drifted twice: one logged the raw
|
||||
// content and the other did not, and one labelled a truncation "chat" where
|
||||
// the other said "chatWithSystem". Two transports mean two chances to
|
||||
// configure the sampler differently, which is exactly how #531 happened.
|
||||
//
|
||||
// where names the calling path, for the truncation line and nothing else.
|
||||
func (p *LLMPhraser) postChat(ctx context.Context, where string, msgs []chatMsg, maxTokens int) (string, error) {
|
||||
base, release, err := p.acquire()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer release()
|
||||
body, err := json.Marshal(chatReq{
|
||||
Messages: msgs,
|
||||
Temperature: p.temperature(),
|
||||
MaxTokens: maxTokens,
|
||||
Grammar: p.grammar(),
|
||||
RepeatPenalty: phraseRepeatPenalty,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("llm: marshal: %w", err)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", base+"/v1/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("llm: request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("llm: post: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("llm: read: %w", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("llm: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var cr chatResp
|
||||
if err := json.Unmarshal(raw, &cr); err != nil {
|
||||
return "", fmt.Errorf("llm: parse: %w", err)
|
||||
}
|
||||
if len(cr.Choices) == 0 {
|
||||
return "", fmt.Errorf("llm: no choices in response")
|
||||
}
|
||||
logIfTruncated(where, cr.Choices[0].FinishReason, maxTokens)
|
||||
content := cr.Choices[0].Message.Content
|
||||
if content == "" {
|
||||
content = cr.Choices[0].Message.ReasoningContent
|
||||
}
|
||||
// Every phrasing path logs its raw generation now. Only chatWithMessages
|
||||
// did, so a nudge or a query that came back unparseable left nothing in the
|
||||
// log to read (V-397).
|
||||
log.Printf("phraser: %s raw content: %q", where, content)
|
||||
return stripThink(content), nil
|
||||
}
|
||||
|
||||
// chatWithSystem is the common shape: one system turn, one user turn.
|
||||
//
|
||||
// The workstation model first when it will take work, and silently: every
|
||||
// caller of this helper is on the silent half of the degradation rule. It
|
||||
// answering is not news, and it being asleep is not news either.
|
||||
func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, maxTokens int) (string, error) {
|
||||
if out, ok := p.remoteChat(ctx, system, user, maxTokens); ok {
|
||||
return out, nil
|
||||
}
|
||||
return p.postChat(ctx, "chatWithSystem", []chatMsg{
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
}, maxTokens)
|
||||
}
|
||||
|
||||
// chatWithMessages sends a full message array (system + history + current) to
|
||||
// the LLM completion endpoint. Like chatWithSystem but for an arbitrary message
|
||||
// slice — the caller owns the system prompt placement.
|
||||
func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTokens int) (string, error) {
|
||||
// Same silent preference as chatWithSystem, when the array is the shape
|
||||
// llm.Req can carry: one system turn and one user turn. PhraseChat already
|
||||
// folds the history into a single user message (some chat templates reject
|
||||
// consecutive user turns), so today that is every call. A longer array goes
|
||||
// to the resident model rather than get flattened here, because flattening a
|
||||
// conversation is a decision its owner should make.
|
||||
if len(msgs) == 2 && msgs[0].Role == "system" && msgs[1].Role == "user" {
|
||||
if out, ok := p.remoteChat(ctx, msgs[0].Content, msgs[1].Content, maxTokens); ok {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
return p.postChat(ctx, "chat", msgs, maxTokens)
|
||||
}
|
||||
Reference in New Issue
Block a user