-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmemory_impl.go
More file actions
1607 lines (1522 loc) · 54.3 KB
/
Copy pathmemory_impl.go
File metadata and controls
1607 lines (1522 loc) · 54.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package mnemos
import (
"context"
"errors"
"fmt"
"maps"
"math"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/felixgeelhaar/chronos"
"github.com/felixgeelhaar/chronos/embed"
"github.com/google/uuid"
axidomain "go.klarlabs.de/axi/domain"
"go.klarlabs.de/bolt"
"go.klarlabs.de/mnemos/internal/domain"
"go.klarlabs.de/mnemos/internal/embedding"
"go.klarlabs.de/mnemos/internal/kernel"
"go.klarlabs.de/mnemos/internal/llm"
"go.klarlabs.de/mnemos/internal/pipeline"
"go.klarlabs.de/mnemos/internal/ports"
"go.klarlabs.de/mnemos/internal/query"
"go.klarlabs.de/mnemos/internal/relate"
"go.klarlabs.de/mnemos/internal/store"
"go.klarlabs.de/mnemos/internal/trust"
"go.klarlabs.de/mnemos/providers"
)
// chronosEventNamespace is the deterministic UUID namespace used when
// hashing string ids (run id, event type) into uuid.UUID for Chronos
// EntityState identifiers. Stable across processes so the same
// (RunID, Type) tuple always maps to the same Chronos series.
var chronosEventNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("mnemos://events"))
// memory is the concrete implementation of [Memory] returned by [New].
type memory struct {
conn *store.Conn
actorID string
info Info // immutable runtime config snapshot, exposed via Info()
cfg config // retained so Tenant() can re-run the New assembly
dsn string // resolved storage DSN, tenant-rewritten by Tenant()
// tenant is the tenant id this view is scoped to, read from the DSN's
// `tenant=` param; it is "" for an unscoped store (no tenant param), which
// preserves the legacy chronos keys. When non-empty it is mixed into the
// chronos EntityID/ScopeID so a SINGLE shared chronos engine keeps tenants
// isolated — chronos scopes by entity/scope, not tenant, so without this
// two tenants' temporal signals would merge.
tenant string
extractor *pipeline.Extractor
relator relate.Engine
query query.Engine
embedder embedding.Client
// chronos is the bundled or user-supplied temporal engine. Always
// non-nil after a successful [New].
chronos *embed.Engine
// chronosOwned is true when [New] constructed the engine itself
// and is responsible for closing it. False when the caller
// supplied one via [WithChronos].
chronosOwned bool
// wkernel is the in-process governed axi kernel every public write
// routes through. Built unconditionally by [New] — there is no
// direct-write fallback, so no write can bypass the evidence chain
// + budget. See governed_writes.go.
wkernel *kernel.Governed
// logger backs the kernel's domain-event log. Nil keeps axi chatter
// off stderr (the library default); cmd/mnemos can supply its own.
logger *bolt.Logger
// lastSession holds the axi session recorded by the most recent
// public write, exposed via [Memory.LastWriteSession]. Guarded by
// sessionMu for concurrent writers.
sessionMu sync.RWMutex
lastSession *axidomain.ExecutionSession
// tokenBudget is the cumulative language-model token budget across all
// writes on this Memory (set via [WithTokenBudget]). Zero means no
// cumulative cap. tokensSpent accumulates the per-write token spend
// reported on each session's "llm" evidence record; once it reaches
// the budget, further writes are rejected pre-execution. Atomic so
// concurrent writers account safely.
tokenBudget int64
tokensSpent atomic.Int64
}
var _ Memory = (*memory)(nil)
// setLastSession records s as the session of the most recent write.
func (m *memory) setLastSession(s *axidomain.ExecutionSession) {
m.sessionMu.Lock()
m.lastSession = s
m.sessionMu.Unlock()
}
// LastWriteSession implements [Memory.LastWriteSession]. Returns nil
// before any write has been performed.
func (m *memory) LastWriteSession() WriteSession {
m.sessionMu.RLock()
s := m.lastSession
m.sessionMu.RUnlock()
if s == nil {
return nil
}
return newWriteSession(s)
}
// Remember implements [Memory.Remember]. The write routes through the
// axi kernel: this method validates input and builds the invocation; the
// rememberExecutor performs the ingest -> extract -> relate -> persist
// pipeline and records the evidence chain. See governed_writes.go.
func (m *memory) Remember(ctx context.Context, item Item) error {
if strings.TrimSpace(item.Content) == "" {
return errors.New("mnemos: Remember: Content is required")
}
_, err := dispatchWrite[rememberOutput](ctx, m, actionRemember, rememberInput{Item: item})
return err
}
// RememberClaim implements [Memory.RememberClaim]. The write routes
// through the axi kernel; the rememberClaimExecutor persists the claim
// (and any evidence links) and records the claim id in the evidence
// chain. The generated claim id is returned to the caller.
func (m *memory) RememberClaim(ctx context.Context, item ClaimItem) (string, error) {
if strings.TrimSpace(item.Text) == "" {
return "", errors.New("mnemos: RememberClaim: Text is required")
}
// Spec non-negotiable: "Claims require evidence. An assertion without
// evidence is rejected." Fail-closed here, before the governed write
// runs, so no evidence-less claim is ever persisted. Blank ids do not
// count as evidence.
if !hasEvidence(item.EventIDs) {
return "", errors.New("mnemos: RememberClaim: at least one evidence EventID is required (claims require evidence)")
}
out, err := dispatchWrite[rememberClaimOutput](ctx, m, actionRememberClaim, rememberClaimInput{Claim: item})
if err != nil {
return "", err
}
m.embedAsync(out.ClaimID, "claim", item.Text)
return out.ClaimID, nil
}
// RecordDecision persists an agent decision (belief -> plan -> outcome audit
// record) through the governed write path and returns its id. Statement is
// required; RiskLevel defaults to "medium" when empty. This is the public
// entry point for the decision audit trail that [Memory.AuditTrail]-style
// consumers read back via [Memory.GetDecision] / [Memory.ListDecisions].
func (m *memory) RecordDecision(ctx context.Context, d Decision) (string, error) {
if strings.TrimSpace(d.Statement) == "" {
return "", errors.New("mnemos: RecordDecision: Statement is required")
}
out, err := dispatchWrite[recordDecisionOutput](ctx, m, actionRecordDecision, recordDecisionInput{Decision: d})
if err != nil {
return "", err
}
// Embed the decision so it is semantically recallable alongside events and
// claims (same async, best-effort embed-on-write path).
m.embedAsync(out.DecisionID, "decision", d.Statement+" "+d.Reasoning)
return out.DecisionID, nil
}
// SetClaimLifecycle implements [Memory.SetClaimLifecycle].
func (m *memory) SetClaimLifecycle(ctx context.Context, claimID string, lifecycle ClaimLifecycle) error {
if strings.TrimSpace(claimID) == "" {
return errors.New("mnemos: SetClaimLifecycle: claimID is required")
}
_, err := dispatchWrite[setClaimLifecycleOutput](ctx, m, actionSetClaimLifecycle, setClaimLifecycleInput{
ClaimID: claimID,
Lifecycle: lifecycle,
})
return err
}
// GetDecision returns the decision with the given id, or a not-found error.
func (m *memory) GetDecision(ctx context.Context, id string) (Decision, error) {
if strings.TrimSpace(id) == "" {
return Decision{}, errors.New("mnemos: GetDecision: id is required")
}
d, err := m.conn.Decisions.GetByID(ctx, id)
if err != nil {
return Decision{}, fmt.Errorf("mnemos: GetDecision: %w", err)
}
return toPublicDecision(d), nil
}
// ListDecisions returns recorded decisions (most-recent first), capped at
// limit (0 = no cap). It is the read side of the decision audit trail.
func (m *memory) ListDecisions(ctx context.Context, limit int) ([]Decision, error) {
all, err := m.conn.Decisions.ListAll(ctx)
if err != nil {
return nil, fmt.Errorf("mnemos: ListDecisions: %w", err)
}
out := make([]Decision, 0, len(all))
for _, d := range all {
out = append(out, toPublicDecision(d))
}
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
// Recall implements [Memory.Recall].
func (m *memory) Recall(ctx context.Context, q Query) ([]Result, error) {
results, _, err := m.recall(ctx, q)
return results, err
}
// RecallWithSufficiency implements [Memory.RecallWithSufficiency]: Recall plus
// the "feeling of knowing" verdict, built from the aggregate answer confidence
// the underlying query already computes (Recall discards it).
func (m *memory) RecallWithSufficiency(ctx context.Context, q Query) ([]Result, Sufficiency, error) {
results, ans, err := m.recall(ctx, q)
if err != nil {
return nil, Sufficiency{}, err
}
return results, sufficiencyOf(ans), nil
}
// RecallWithEffort implements [Memory.RecallWithEffort]: a first pass, then one
// bounded wider+deeper pass when the Expected-Value-of-Control budget
// (stakes × (1 − first-pass confidence)) warrants it and the first pass was not
// already sufficient. Non-regressive: the escalated pass is adopted only when it
// is stronger (at least as many claims AND higher confidence).
func (m *memory) RecallWithEffort(ctx context.Context, q Query, stakes float64) ([]Result, Sufficiency, EffortReport, error) {
baseResults, baseAns, err := m.recall(ctx, q)
if err != nil {
return nil, Sufficiency{}, EffortReport{}, err
}
baseBreadth := q.Limit
if baseBreadth <= 0 {
baseBreadth = len(baseResults) // no explicit cap — the breadth that came back
}
report := EffortReport{
Budget: EffortBudget(stakes, baseAns.Confidence),
Passes: 1,
Hops: q.Hops,
Breadth: baseBreadth,
}
baseSuf := sufficiencyOf(baseAns)
// Escalate a single bounded pass only when the stakes-weighted uncertainty
// warrants it AND we are not already grounded.
if report.Budget < EffortEscalationThreshold || baseSuf.Sufficient {
return baseResults, baseSuf, report, nil
}
eq := q
eq.Hops = q.Hops + int(math.Ceil(report.Budget*EffortMaxExtraHops))
eq.Limit = baseBreadth + int(math.Round(report.Budget*EffortMaxExtraBreadth))
report.Passes = 2
escResults, escAns, eerr := m.recall(ctx, eq)
if eerr == nil && strongerAnswer(escAns, baseAns) {
report.Hops = eq.Hops
report.Breadth = eq.Limit
return escResults, sufficiencyOf(escAns), report, nil
}
// Escalation did not help (or errored): keep the first pass. The effort was
// spent (Passes == 2) but the answer is unchanged — never regressed.
return baseResults, baseSuf, report, nil
}
// RecallWithContext implements [Memory.RecallWithContext]: base query recall, then
// a stable re-rank that promotes results connected (over the epistemic graph) to
// the caller's current context — spreading activation from the train of thought.
func (m *memory) RecallWithContext(ctx context.Context, q Query, activeContext string) ([]Result, error) {
results, _, err := m.recall(ctx, q)
if err != nil {
return nil, err
}
activeContext = strings.TrimSpace(activeContext)
if activeContext == "" || len(results) < 2 {
return results, nil // no context, or nothing to reorder
}
// Spread activation: expand the context over the epistemic graph and record a
// decaying activation per reached claim. Hop expansion IS the spread. Reuse the
// query's Lifecycle so the activation source respects the same visibility.
activated, _, aerr := m.recall(ctx, Query{Text: activeContext, Hops: ActivationHops, Lifecycle: q.Lifecycle})
if aerr != nil {
return results, nil // best-effort: spreading failed → plain recall order
}
activation := make(map[string]float64, len(activated))
for _, a := range activated {
act := math.Pow(ActivationDecay, float64(a.HopDistance))
if act > activation[a.ClaimID] {
activation[a.ClaimID] = act
}
}
if len(activation) == 0 {
return results, nil
}
// Stable re-rank: base score preserves the recall order (len-i); activation
// promotes connected results by up to ActivationBoost positions.
type scored struct {
r Result
s float64
}
ranked := make([]scored, len(results))
for i, r := range results {
ranked[i] = scored{r, float64(len(results)-i) + ActivationBoost*activation[r.ClaimID]}
}
sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].s > ranked[j].s })
out := make([]Result, len(ranked))
for i, sc := range ranked {
out[i] = sc.r
}
return out, nil
}
// RecallWithConflicts implements [Memory.RecallWithConflicts]: base recall plus the
// contested frontier — for each recalled claim, the currently-valid claims that
// contradict it over the epistemic graph. Strongest challenger first.
func (m *memory) RecallWithConflicts(ctx context.Context, q Query) ([]Result, []Conflict, error) {
results, _, err := m.recall(ctx, q)
if err != nil {
return nil, nil, err
}
if len(results) == 0 {
return results, nil, nil
}
resultByID := make(map[string]Result, len(results))
ids := make([]string, len(results))
for i, r := range results {
ids[i] = r.ClaimID
resultByID[r.ClaimID] = r
}
rels, err := m.conn.Relationships.ListByClaimIDs(ctx, ids)
if err != nil {
return results, nil, nil // best-effort: no graph → no conflicts, results stand
}
// Endpoints not already recalled need a validity check before they can count
// as live counter-evidence (a superseded/invalidated challenger is resolved).
need := map[string]struct{}{}
for _, rel := range rels {
if rel.Type != domain.RelationshipTypeContradicts {
continue
}
for _, id := range []string{rel.FromClaimID, rel.ToClaimID} {
if _, ok := resultByID[id]; !ok {
need[id] = struct{}{}
}
}
}
valid := map[string]domain.Claim{}
if len(need) > 0 {
list := make([]string, 0, len(need))
for id := range need {
list = append(list, id)
}
if claims, cerr := m.conn.Claims.ListByIDs(ctx, list); cerr == nil {
for _, c := range claims {
if c.ValidTo.IsZero() && c.Lifecycle != domain.ClaimLifecycleSuperseded {
valid[c.ID] = c
}
}
}
}
// challengerOf returns the text/trust of a claim usable as counter-evidence,
// whether it was itself recalled (valid by construction) or externally valid.
challengerOf := func(id string) (text string, trust float64, ok bool) {
if r, isResult := resultByID[id]; isResult {
return r.Text, r.TrustScore, true
}
if c, isValid := valid[id]; isValid {
return c.Text, c.TrustScore, true
}
return "", 0, false
}
var conflicts []Conflict
seen := map[string]struct{}{}
for _, rel := range rels {
if rel.Type != domain.RelationshipTypeContradicts {
continue
}
// A contradiction is undirected here: whichever endpoint is recalled is the
// contested claim, the other (if a live claim) is its counter-evidence.
for _, pair := range [2][2]string{{rel.FromClaimID, rel.ToClaimID}, {rel.ToClaimID, rel.FromClaimID}} {
recalledID, otherID := pair[0], pair[1]
recalled, isRecalled := resultByID[recalledID]
if !isRecalled {
continue
}
text, trust, ok := challengerOf(otherID)
if !ok {
continue
}
key := recalledID + "\x00" + otherID
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
conflicts = append(conflicts, Conflict{
ClaimID: recalledID, ClaimText: recalled.Text,
ContradictingID: otherID, ContradictingText: text, ContradictingTrust: trust,
})
}
}
// Strongest counter-evidence first.
sort.SliceStable(conflicts, func(i, j int) bool {
return conflicts[i].ContradictingTrust > conflicts[j].ContradictingTrust
})
return results, conflicts, nil
}
// sufficiencyOf builds the public "feeling of knowing" from a raw answer.
func sufficiencyOf(ans domain.Answer) Sufficiency {
return Sufficiency{
Confidence: ans.Confidence,
ClaimCount: len(ans.Claims),
Sufficient: len(ans.Claims) > 0 && ans.Confidence >= RecallSufficiencyFloor,
Floor: RecallSufficiencyFloor,
}
}
// strongerAnswer reports whether b is a non-regressive improvement over a: at
// least as many claims AND strictly higher confidence. Mirrors the rule the
// internal corrective-retrieval pass uses so effort never trades breadth for a
// marginally higher score.
func strongerAnswer(b, a domain.Answer) bool {
return len(b.Claims) >= len(a.Claims) && b.Confidence > a.Confidence
}
// Blocks implements [Memory.Blocks].
func (m *memory) Blocks(ctx context.Context, owner string) ([]Block, error) {
if m.conn.Blocks == nil {
return nil, ErrBlocksUnsupported
}
rows, err := m.conn.Blocks.List(ctx, strings.TrimSpace(owner))
if err != nil {
return nil, fmt.Errorf("mnemos: Blocks: %w", err)
}
out := make([]Block, 0, len(rows))
for _, b := range rows {
out = append(out, Block{Owner: b.Owner, Label: b.Label, Value: b.Content, UpdatedAt: b.UpdatedAt})
}
return out, nil
}
// SetBlock implements [Memory.SetBlock]. An empty value deletes the block; an
// over-limit value is rejected (working memory is bounded on purpose).
func (m *memory) SetBlock(ctx context.Context, owner, label, value string) error {
if m.conn.Blocks == nil {
return ErrBlocksUnsupported
}
owner, label = strings.TrimSpace(owner), strings.TrimSpace(label)
if owner == "" || label == "" {
return errors.New("mnemos: SetBlock: owner and label are required")
}
if len(value) > WorkingMemoryBlockLimit {
return ErrBlockTooLarge
}
if value == "" {
return m.conn.Blocks.Delete(ctx, owner, label)
}
return m.conn.Blocks.Upsert(ctx, domain.WorkingMemoryBlock{
Owner: owner, Label: label, Content: value, UpdatedAt: time.Now().UTC(),
})
}
// AppendBlock implements [Memory.AppendBlock]: append a line, then evict oldest
// lines to stay within the block limit.
func (m *memory) AppendBlock(ctx context.Context, owner, label, text string) error {
if m.conn.Blocks == nil {
return ErrBlocksUnsupported
}
owner, label = strings.TrimSpace(owner), strings.TrimSpace(label)
if owner == "" || label == "" {
return errors.New("mnemos: AppendBlock: owner and label are required")
}
cur, _, err := m.conn.Blocks.Get(ctx, owner, label)
if err != nil {
return fmt.Errorf("mnemos: AppendBlock: %w", err)
}
combined := cur.Content
if combined != "" && text != "" {
combined += "\n"
}
combined += text
combined = evictToLimit(combined, WorkingMemoryBlockLimit)
return m.conn.Blocks.Upsert(ctx, domain.WorkingMemoryBlock{
Owner: owner, Label: label, Content: combined, UpdatedAt: time.Now().UTC(),
})
}
// evictToLimit trims content to fit limit by dropping WHOLE lines from the FRONT
// (oldest first) — the working-memory eviction policy: the cap is the attention
// budget, and the freshest lines win. If a single trailing line still exceeds the
// limit it is hard-truncated to its last `limit` characters.
func evictToLimit(s string, limit int) string {
if len(s) <= limit {
return s
}
lines := strings.Split(s, "\n")
for len(lines) > 1 && len(strings.Join(lines, "\n")) > limit {
lines = lines[1:]
}
out := strings.Join(lines, "\n")
if len(out) > limit {
out = out[len(out)-limit:]
}
return out
}
// recall is the shared core: it runs the query and maps claims to results,
// returning the raw domain.Answer too so callers that want the aggregate
// confidence (RecallWithSufficiency) can read it without a second pass.
func (m *memory) recall(ctx context.Context, q Query) ([]Result, domain.Answer, error) {
if strings.TrimSpace(q.Text) == "" {
return nil, domain.Answer{}, errors.New("mnemos: Recall: Text is required")
}
opts := query.AnswerOptions{
Hops: q.Hops,
AsOf: q.AsOf,
RecordedAsOf: q.RecordedAsOf,
IncludeHistory: q.IncludeHistory,
Lifecycle: domain.ClaimLifecycle(q.Lifecycle),
}
var ans domain.Answer
var err error
if q.RunID != "" {
ans, err = m.query.AnswerForRunWithOptions(q.Text, q.RunID, opts)
} else {
ans, err = m.query.AnswerWithOptions(q.Text, opts)
}
if err != nil {
return nil, domain.Answer{}, fmt.Errorf("mnemos: answer: %w", err)
}
results := make([]Result, 0, len(ans.Claims))
for _, c := range ans.Claims {
results = append(results, Result{
ClaimID: c.ID,
Text: c.Text,
Type: string(c.Type),
Confidence: c.Confidence,
TrustScore: c.TrustScore,
HopDistance: ans.ClaimHopDistance[c.ID],
Provenance: ans.ClaimProvenance[c.ID],
})
}
if q.Limit > 0 && len(results) > q.Limit {
results = results[:q.Limit]
}
// Suppress unused-context: the underlying engine is sync today; ctx
// is reserved for future cancellation propagation through the
// query.Engine surface.
_ = ctx
return results, ans, nil
}
// Get implements [Memory.Get]. Exact lookup by claim id. Returns a
// not-found error when the id is unknown.
func (m *memory) Get(ctx context.Context, claimID string) (Claim, error) {
id := strings.TrimSpace(claimID)
if id == "" {
return Claim{}, errors.New("mnemos: Get: claimID is required")
}
claims, err := m.conn.Claims.ListByIDs(ctx, []string{id})
if err != nil {
return Claim{}, fmt.Errorf("mnemos: Get: %w", err)
}
for _, c := range claims {
if c.ID == id {
return toPublicClaim(c), nil
}
}
return Claim{}, fmt.Errorf("mnemos: Get: claim %q not found", id)
}
// Scan implements [Memory.Scan]. Returns claims whose valid-time interval
// overlaps the [ScanQuery] window, ordered by ValidFrom, honouring Limit.
func (m *memory) Scan(ctx context.Context, q ScanQuery) ([]Claim, error) {
all, err := m.conn.Claims.ListAll(ctx)
if err != nil {
return nil, fmt.Errorf("mnemos: Scan: %w", err)
}
out := make([]Claim, 0, len(all))
for _, c := range all {
if !claimOverlapsWindow(c, q.ValidFrom, q.ValidUntil) {
continue
}
out = append(out, toPublicClaim(c))
}
sort.Slice(out, func(i, j int) bool { return out[i].ValidFrom.Before(out[j].ValidFrom) })
if q.Limit > 0 && len(out) > q.Limit {
out = out[:q.Limit]
}
return out, nil
}
// claimOverlapsWindow reports whether a claim's valid-time interval
// [ValidFrom, ValidTo) overlaps the [from, until) window. Zero bounds on
// either side act as open (unbounded) edges. A claim with a zero ValidFrom
// is treated as "valid since before tracking began" (negative infinity);
// a zero ValidTo means "still valid" (positive infinity).
func claimOverlapsWindow(c domain.Claim, from, until time.Time) bool {
// Claim ends before the window starts → no overlap.
if !from.IsZero() && !c.ValidTo.IsZero() && !c.ValidTo.After(from) {
return false
}
// Claim starts at/after the window ends → no overlap.
if !until.IsZero() && !c.ValidFrom.IsZero() && !c.ValidFrom.Before(until) {
return false
}
return true
}
// toPublicClaim projects an internal domain.Claim into the stable public
// [Claim] read shape. ValidTo's zero value ("still valid") maps to a nil
// ValidUntil; CreatedAt maps to the transaction-time RecordedAt.
func toPublicClaim(c domain.Claim) Claim {
out := Claim{
ID: c.ID,
Statement: c.Text,
Type: string(c.Type),
Confidence: c.Confidence,
TrustScore: c.TrustScore,
ValidFrom: c.ValidFrom,
RecordedAt: c.CreatedAt,
Lifecycle: ClaimLifecycle(c.Lifecycle),
}
if !c.ValidTo.IsZero() {
vt := c.ValidTo
out.ValidUntil = &vt
}
return out
}
// toPublicDecision maps the internal domain decision onto the public shape.
func toPublicDecision(d domain.Decision) Decision {
return Decision{
ID: d.ID,
Statement: d.Statement,
Plan: d.Plan,
Reasoning: d.Reasoning,
RiskLevel: string(d.RiskLevel),
Beliefs: d.Beliefs,
Alternatives: d.Alternatives,
OutcomeID: d.OutcomeID,
ChosenAt: d.ChosenAt,
CreatedBy: d.CreatedBy,
CreatedAt: d.CreatedAt,
}
}
// RememberEvent implements [Memory.RememberEvent]. The write routes
// through the axi kernel; the rememberEventExecutor appends the event,
// forwards it to the bundled Chronos engine, and records the event id in
// the evidence chain. The action is idempotent on event id.
func (m *memory) RememberEvent(ctx context.Context, e Event) error {
if e.At.IsZero() {
return errors.New("mnemos: RememberEvent: At is required")
}
if strings.TrimSpace(e.Content) == "" {
return errors.New("mnemos: RememberEvent: Content is required")
}
e.ID = strings.TrimSpace(e.ID)
out, err := dispatchWrite[rememberEventOutput](ctx, m, actionRememberEvent, rememberEventInput{Event: e})
if err != nil {
return err
}
m.embedAsync(out.EventID, "event", e.Content)
return nil
}
// embedAsync embeds text and stores the vector for (entityID, entityType) in the
// BACKGROUND, so recall can rank it semantically without adding an embedding
// round-trip to the write path. It is:
//
// - opt-in: a no-op unless an embedder is configured (WithSharedProvider /
// WithEnhancedMode) and the backend has an embeddings repository;
// - best-effort: it never blocks or fails the originating write — an embedding
// is derived, recomputable state (this mirrors [pipeline.GenerateEmbeddings],
// which also Upserts embeddings directly rather than through the write kernel);
// - idempotent: the upsert is keyed on (entity_id, entity_type), so a re-embed
// (model change, backfill) overwrites cleanly.
func (m *memory) embedAsync(entityID, entityType, text string) {
if m.embedder == nil || m.conn == nil || m.conn.Embeddings == nil {
return
}
if entityID == "" || strings.TrimSpace(text) == "" {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
vectors, err := m.embedder.Embed(ctx, []string{text})
if err != nil || len(vectors) == 0 || len(vectors[0]) == 0 {
if m.logger != nil {
m.logger.Ctx(ctx).Warn().Err(err).Str("entity_id", entityID).Str("entity_type", entityType).Msg("mnemos: async embed skipped")
}
return
}
if err := m.conn.Embeddings.Upsert(ctx, entityID, entityType, vectors[0], embedding.ModelIDOf(m.embedder), m.actorID); err != nil {
if m.logger != nil {
m.logger.Ctx(ctx).Warn().Err(err).Str("entity_id", entityID).Str("entity_type", entityType).Msg("mnemos: async embed upsert failed")
}
}
}()
}
// eventToEntityState maps a public mnemos.Event into a chronos.EntityState
// suitable for the bundled in-process engine. See [memory.RememberEvent]
// for the mapping rationale.
func (m *memory) eventToEntityState(e Event, eventID string) chronos.EntityState {
typ := strings.TrimSpace(e.Type)
if typ == "" {
typ = "untyped"
}
runID := e.RunID
if runID == "" {
runID = "default"
}
meta := map[string]string{
"mnemos_event_id": eventID,
"event_type": typ,
}
if c := strings.TrimSpace(e.Content); c != "" {
if len(c) > 200 {
c = c[:200]
}
meta["content_preview"] = c
}
// Tenant-scope the entity + scope keys so a single shared chronos engine
// never merges two tenants' series (chronos isolates by entity/scope, not
// tenant). Tenant ids are NUL-free (validated by tenantRE in Tenant()), so
// the NUL separator unambiguously delimits the tenant prefix from the
// value — a tenant-prefixed key can never equal an unscoped key.
entityKey := typ
if m.tenant != "" {
entityKey = m.tenant + "\x00" + typ
meta["tenant"] = m.tenant
}
// Use the event's real numeric features when present so Chronos detects
// patterns on actual VALUES; fall back to the presence default (a single
// 1.0) so cadence is still tracked for presence-only events.
features := e.Features
if len(features) == 0 {
features = []float64{1.0}
}
return chronos.EntityState{
ID: uuid.New(),
EntityID: uuid.NewSHA1(chronosEventNamespace, []byte(entityKey)),
ScopeID: m.chronosScopeID(runID),
Timestamp: e.At.UTC(),
Features: features,
Labels: []string{"event"},
Meta: meta,
}
}
// isValidClaimType reports whether t is acceptable for a write: a built-in
// claim type, or one the consumer registered via WithClaimTypes. This is the
// configurable-vocabulary check the write executor enforces at the boundary.
func (m *memory) isValidClaimType(t domain.ClaimType) bool {
if domain.IsBuiltinClaimType(t) {
return true
}
for _, extra := range m.cfg.extraClaimTypes {
if domain.ClaimType(extra) == t {
return true
}
}
return false
}
// chronosScopeID derives the Chronos scope UUID for a run, applying the same
// tenant mix + default-run rule eventToEntityState uses to STORE events — so
// [Memory.Signals] reads signals from the exact scope events were written under.
func (m *memory) chronosScopeID(runID string) uuid.UUID {
if runID == "" {
runID = "default"
}
scopeKey := runID
if m.tenant != "" {
scopeKey = m.tenant + "\x00" + runID
}
return uuid.NewSHA1(chronosEventNamespace, []byte(scopeKey))
}
// Timeline implements [Memory.Timeline].
func (m *memory) Timeline(ctx context.Context, q TimelineQuery) ([]Event, error) {
var events []domain.Event
var err error
if q.RunID != "" {
events, err = m.conn.Events.ListByRunID(ctx, q.RunID)
} else {
events, err = m.conn.Events.ListAll(ctx)
}
if err != nil {
return nil, fmt.Errorf("mnemos: list events: %w", err)
}
out := make([]Event, 0, len(events))
for _, ev := range events {
if !q.From.IsZero() && ev.Timestamp.Before(q.From) {
continue
}
if !q.To.IsZero() && ev.Timestamp.After(q.To) {
continue
}
typ := ev.Metadata["event_type"]
if len(q.Types) > 0 && !slices.Contains(q.Types, typ) {
continue
}
out = append(out, Event{
ID: ev.ID,
At: ev.Timestamp,
Type: typ,
Content: ev.Content,
Metadata: copyMetaWithout(ev.Metadata, "event_type"),
RunID: ev.RunID,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
if q.Limit > 0 && len(out) > q.Limit {
out = out[:q.Limit]
}
return out, nil
}
// Signals implements [Memory.Signals]. It asks the bundled Chronos engine to
// detect temporal patterns over the run's scope (recomputing from the stored
// event series, so the read reflects everything ingested so far), then projects
// them onto the public [Signal] shape. No Chronos wired, or nothing detected,
// yields (nil, nil) — an empty result means "all quiet", never an error.
func (m *memory) Signals(ctx context.Context, q SignalQuery) ([]Signal, error) {
if m.chronos == nil {
return nil, nil
}
scopeID := m.chronosScopeID(q.RunID)
detected, err := m.chronos.Detect(ctx, []uuid.UUID{scopeID})
if err != nil {
return nil, fmt.Errorf("mnemos: detect signals: %w", err)
}
runID := q.RunID
if runID == "" {
runID = "default"
}
out := make([]Signal, 0, len(detected))
for _, s := range detected {
if q.MinConfidence > 0 && s.Confidence < q.MinConfidence {
continue
}
out = append(out, Signal{
Pattern: string(s.Pattern),
RunID: runID,
Strength: s.Strength,
Confidence: s.Confidence,
Class: string(s.ConfidenceClass),
DetectedAt: s.DetectedAt,
WindowStart: s.Window.Start,
WindowEnd: s.Window.End,
})
if q.Limit > 0 && len(out) == q.Limit {
break
}
}
return out, nil
}
// Consolidate implements [Memory.Consolidate] — the maintenance "sleep" pass.
func (m *memory) Consolidate(ctx context.Context, opts ConsolidateOptions) (ConsolidateResult, error) {
threshold := opts.DedupeThreshold
if threshold <= 0 {
threshold = 0.92
}
plan, err := pipeline.PlanSemanticDedupe(ctx, m.conn, threshold)
if err != nil {
return ConsolidateResult{}, fmt.Errorf("mnemos: consolidate: plan dedupe: %w", err)
}
res := ConsolidateResult{ClaimsScanned: plan.ClaimsScanned, DuplicateGroups: len(plan.Merges)}
if opts.DryRun {
// Preview only — report what dedupe WOULD collapse; no mutation, and
// forgetting (which acts on freshly-recomputed trust) is not simulated.
return res, nil
}
if len(plan.Merges) > 0 {
merged, err := pipeline.ApplySemanticDedupe(ctx, m.conn, plan)
if err != nil {
return res, fmt.Errorf("mnemos: consolidate: apply dedupe: %w", err)
}
res.Merged = merged
}
// Recompute trust (confidence × corroboration × freshness) — the
// renormalisation half of the sleep pass. Merging changed evidence counts,
// and freshness decays with wall-clock time regardless. Best-effort: a
// scorer-less backend still consolidated correctly.
if scorer, ok := m.conn.Claims.(ports.TrustScorer); ok {
now := time.Now().UTC()
if n, terr := scorer.RecomputeTrust(ctx, func(confidence float64, evidenceCount int, latestEvidence time.Time) float64 {
return trust.Score(confidence, evidenceCount, latestEvidence, now)
}); terr == nil {
res.TrustRefreshed = n
}
}
// Active forgetting: prune stale, low-trust, non-promoted claims — the
// offline synaptic renormalisation the brain does during sleep. Reduced
// retrievability, not erasure (marked deprecated, history preserved).
if opts.ForgetBelowTrust > 0 {
forgotten, ferr := m.forgetStaleClaims(ctx, opts.ForgetBelowTrust)
if ferr != nil {
return res, fmt.Errorf("mnemos: consolidate: forget: %w", ferr)
}
res.Forgotten = forgotten
}
// Surprise-driven forgetting: a belief an observed outcome REFUTED should stop
// surfacing — the prediction-error loop closing. Independent of trust decay.
if opts.ForgetRefuted {
refuted, rerr := m.forgetRefutedClaims(ctx)
if rerr != nil {
return res, fmt.Errorf("mnemos: consolidate: forget refuted: %w", rerr)
}
res.Refuted = refuted
}
// Confirmation routing: freshen claims a later outcome BORE OUT, so validated
// beliefs resist forgetting — the mirror of ForgetRefuted. Together they route
// prediction-error both ways: confirmed kept fresh, refuted let go.
if opts.ReinforceValidated {
validated, verr := m.reinforceValidatedClaims(ctx)
if verr != nil {
return res, fmt.Errorf("mnemos: consolidate: reinforce validated: %w", verr)
}
res.Validated = validated
}
// Skill synthesis: re-derive lessons + playbooks from accumulated experience —
// the auto-trigger arrow that keeps the skill layer current without a manual CLI
// run. Runs before reinforcement so freshly-derived playbooks tune this pass.
if opts.Synthesize {
sr, serr := m.Synthesize(ctx)
if serr != nil {
return res, fmt.Errorf("mnemos: consolidate: synthesize: %w", serr)
}
res.LessonsSynthesized = sr.LessonsDerived
res.PlaybooksSynthesized = sr.PlaybooksDerived
}
// Skill reinforcement: bend each playbook's confidence toward the observed
// success rate of the outcomes on the actions its lessons were derived from —
// the learning half of the sleep pass, closing the loop so a write-once skill
// store becomes self-tuning. Deterministic, no LLM; independent of the
// forgetting steps above.
if opts.ReinforcePlaybooks {
reinforced, perr := m.reinforcePlaybooks(ctx)
if perr != nil {
return res, fmt.Errorf("mnemos: consolidate: reinforce playbooks: %w", perr)
}
res.PlaybooksReinforced = reinforced
}
// Prioritized replay: rehearse the most important memories so they resist the
// decay that prunes the mundane — the SWS rehearsal stage. Forward-looking (it
// freshens for future passes), so it runs last.
if opts.ReplayTopK > 0 {
replayed, rperr := m.replayTopK(ctx, opts.ReplayTopK)
if rperr != nil {
return res, fmt.Errorf("mnemos: consolidate: replay: %w", rperr)
}
res.Replayed = replayed
}
return res, nil
}
// replayRecencyHalfLifeDays sets how fast replay priority decays with a claim's
// age — recent memories are rehearsed preferentially (recency-weighted replay).
const replayRecencyHalfLifeDays = 30.0
// replayTopK rehearses the K currently-valid claims of highest priority
// (salience × recency) by bumping their freshness — prioritized experience replay.
func (m *memory) replayTopK(ctx context.Context, k int) (int, error) {
if k <= 0 {
return 0, nil
}
all, err := m.conn.Claims.ListAll(ctx)
if err != nil {
return 0, fmt.Errorf("list claims: %w", err)
}
evidence, err := m.conn.Claims.ListAllEvidence(ctx)
if err != nil {
return 0, fmt.Errorf("list evidence: %w", err)
}
evidenceCount := make(map[string]int, len(all))
for _, e := range evidence {
evidenceCount[e.ClaimID]++
}
now := time.Now().UTC()
type pri struct {
id string
score float64
}
ranked := make([]pri, 0, len(all))
for _, c := range all {
if !c.ValidTo.IsZero() {