Merge the invented note and the repeated ask (#238)

V-592 is a phrasing defect. The store was never wrong: DefaultFactParser files
я выпил воды as key=water value=drank, and no стакан reaches the index. The
glass was copied out of the prompt. ReplySystemPrompt's example was literally
'Записала, что ты выпил стакан воды', replyContext hands the model
'записала факт: water "drank"' with no Russian to work from, and the nearest
plausible sentence in context was the example itself. выпел is the 1.7B
garbling the verb.

So the fact path stops generating and echoes, per V-576. The prompt example is
contentless now. Two smaller things fell out: the stub read the parser's key
back at him as 'отметила: water = "drank"', and a fact clarified out of запиши
confirmed as запиши, because a fact answer fills no Text slot.

V-593: whenKnownOf reads the three things he must say off the same predicates
whenGapOf uses. An answer that moved any of them forward puts 'Поняла: <his
words>.' between the clock and the question. An answer that moved nothing
repeats the question unchanged, which is honest. The acknowledgement echoes and
never restates, for the same reason as V-592.

A new differs field on a trace turn fails a byte-identical consecutive reply.

Open for the owner: whether the clock repeats on every ask of one flow. He
ruled that she states the time, not that she states it on every question.

(V-592) (V-593)
This commit is contained in:
2026-08-06 02:59:29 +04:00
11 changed files with 201 additions and 37 deletions
+6 -1
View File
@@ -5,7 +5,8 @@
"What she says after storing something he said, and what she says when storing it failed. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
"He hears these many times a day, so most entries carry variants: identical wording is what makes a confirmation stop registering as one.",
"Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title. His data is interpolated Go-side — the file holds the frame, never his words.",
"Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title or, in ack_fact_echo, his own sentence. His data is interpolated Go-side — the file holds the frame, never his words.",
"ack_fact_echo is the one entry with a single variant, deliberately: what varies in it is his own sentence, which is different every time, and the frame around it is what the simulator scenarios read back.",
"An acknowledgement confirms and stops. It does not ask a follow-up question and it does not editorialise about what he stored."
],
"entries": {
@@ -18,6 +19,10 @@
"ack_fact_kv": {
"variants": ["отметила: {key} = {value}", "записала: {key} — {value}", "запомнила: {key} — {value}"]
},
"ack_fact_echo": {
"fixed": true,
"variants": ["записала: {text}"]
},
"ack_note": {
"variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
},
+25 -1
View File
@@ -15,6 +15,7 @@ import (
_ "embed"
"log"
"math/rand"
"strings"
"sync"
"github.com/kami/maven/internal/say"
@@ -33,6 +34,7 @@ const (
AckFact = "ack_fact"
AckFactKey = "ack_fact_key"
AckFactValue = "ack_fact_kv"
AckFactEcho = "ack_fact_echo"
AckNote = "ack_note"
AckReminder = "ack_reminder"
AckAct = "ack_act"
@@ -58,7 +60,7 @@ const (
// ackKeys — every key the code requires the file to define.
var ackKeys = []string{
AckFact, AckFactKey, AckFactValue, AckNote, AckReminder, AckAct,
AckFact, AckFactKey, AckFactValue, AckFactEcho, AckNote, AckReminder, AckAct,
AckTask, AckTaskUrgent, AckTaskDuplicate, AckNudge, AckSnooze, AckGeneric,
AckQuietOn, AckQuietOff,
FailFact, FailFactUnparsed, FailNote, FailReminder, FailReminderTime,
@@ -71,6 +73,7 @@ var ackFloor = map[string]string{
AckFact: "записала факт.",
AckFactKey: "отметила: {key}",
AckFactValue: "отметила: {key} = {value}",
AckFactEcho: "записала: {text}",
AckNote: "сохранила заметку.",
AckReminder: "напомню.",
AckAct: "ок, записала действие: {fn}",
@@ -109,6 +112,7 @@ func LoadAcks(src rand.Source) (*Acks, error) {
// captured, which reads as a successful save of nothing.
for _, req := range []struct{ key, ph string }{
{AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
{AckFactEcho, "{text}"},
{AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"},
} {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
@@ -157,6 +161,26 @@ func DefaultAcks() *Acks {
// Ack — one acknowledgement line, the way every caller says it.
func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(key, vars) }
// FactAck — the confirmation for a captured fact, in the words he used (V-592).
//
// It is a deck line with his sentence dropped into it, and there is no
// generation anywhere on this path. Asking a 1.7B to say his sentence back
// produced "Проверила, что ты выпел стакан воды" for "я выпил воды": a non-word
// for the verb, a glass he never mentioned — lifted straight out of the example
// in ReplySystemPrompt — and a claim to have checked something. The fact store
// held key=water value="drank" throughout, so nothing was mis-captured and
// everything after the capture was invented.
//
// An empty utterance falls back to the contentless line rather than confirming
// a capture of nothing.
func FactAck(utterance string) string {
utterance = strings.TrimSpace(utterance)
if utterance == "" {
return Ack(AckFact, nil)
}
return Ack(AckFactEcho, map[string]string{"text": utterance})
}
// IsAck reports whether text is a line key could have produced. For the daemon
// tests, which can no longer compare against one literal.
func IsAck(key string, vars map[string]string, text string) bool {
+6 -1
View File
@@ -30,8 +30,13 @@ const replyTimeout = 60 * time.Second
// ReplySystemPrompt — the reactive confirmation contract: one short Russian
// sentence, feminine self-reference, informal address, no question.
//
// The example is deliberately contentless. It used to be "Записала, что ты
// выпил стакан воды.", and the model copied the glass into a real reply about
// water he never described that way (V-592). An example carrying a plausible
// completion of the input is an invitation to reuse it.
const ReplySystemPrompt = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), по-русски, спокойно и без официальных формулировок. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"}
Пример: {"response": "Хорошо, напомню.", "mood": "neutral"}
Никогда не пиши "..." в поле response.`
// Replier phrases reactive confirmations with the resident model. It has no
+4 -7
View File
@@ -67,13 +67,10 @@ func (s *StubReplier) Reply(d router.Decision) string {
case router.IntentReminder:
return phraser.Ack(phraser.AckReminder, nil)
case router.IntentFact:
if d.Slots.HasKey {
if d.Slots.Value != "" {
return phraser.Ack(phraser.AckFactValue, map[string]string{"key": d.Slots.Key, "value": d.Slots.Value})
}
return phraser.Ack(phraser.AckFactKey, map[string]string{"key": d.Slots.Key})
}
return phraser.Ack(phraser.AckFact, nil)
// His words, not the key the parser filed them under (V-592). The key
// is machine vocabulary — "water", "meal" — and reading it back was
// never a confirmation he could check.
return phraser.FactAck(d.Utterance)
case router.IntentNote:
return phraser.Ack(phraser.AckNote, nil)
case router.IntentQuery: