From 32972ab809bbc022917272686acd512713af1e96 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:36:11 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(memory):=20extended-memory=20extraction?= =?UTF-8?q?=20quality=20=E2=80=94=20drop=20noise=20atoms,=20per-class=20TT?= =?UTF-8?q?L,=20type=20quotas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extractor prompt gains example-first REJECT guidance (provenance-in-text, release ephemera, self-referential bookkeeping) and generalizing ACCEPTs. - Write-time quality validator (qualityViolation) enforces the prompt's contract mechanically: session IDs, turn numbers, PR numbers, commit hashes, version tags, pending_review references, and already-stored restatements never persist as atom text. - Ephemeral atom classes (intent, goal, error, question, file) now carry a TTL (default 14 days, ephemeral_ttl_days) and stop being recalled when expired; durable classes and pinned atoms are exempt. - Extraction runs are quota-bounded: max 3 atoms per type and 8 per run, quality-ranked by confidence so trimming keeps the strongest memories. - docs/CONFIG.md documents ephemeral_ttl_days. --- docs/CONFIG.md | 2 + internal/memory/extended/config.go | 2 + internal/memory/extended/extractor.go | 96 ++++++++++++++++++- .../memory/extended/extractor_quality_test.go | 88 +++++++++++++++++ .../memory/extended/extractor_quota_test.go | 79 +++++++++++++++ internal/memory/extended/recall.go | 1 + internal/memory/extended/ttl.go | 46 +++++++++ internal/memory/extended/ttl_test.go | 53 ++++++++++ 8 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 internal/memory/extended/extractor_quality_test.go create mode 100644 internal/memory/extended/extractor_quota_test.go create mode 100644 internal/memory/extended/ttl.go create mode 100644 internal/memory/extended/ttl_test.go diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 1e38f140..1a633dfa 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -594,6 +594,7 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md] "memory_budget_chars": 2000, "decay_half_life_days": 30, "quarantine_ttl_days": 7, + "ephemeral_ttl_days": 14, "eviction_policy": "retention_decay", "predictive_intents": 3, "auto_extract_per_turn": true, @@ -630,6 +631,7 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md] | `memory_budget_chars` | `2000` | `ODEK_MEMORY_EXTENDED_MEMORY_BUDGET_CHARS` | `--memory-extended-memory-budget-chars` | Maximum injected Extended Memory context per turn. | | `decay_half_life_days` | `30` | — | — | Days until an atom's recall/eviction weight halves. | | `quarantine_ttl_days` | `7` | — | — | Days before a tainted atom is auto-deleted from quarantine. | +| `ephemeral_ttl_days` | `14` | — | — | Days before ephemeral-class atoms (intent, goal, error, question, file) stop being recalled. Durable classes (preference, convention, fact, decision) never expire via TTL; pinned atoms are exempt. | | `eviction_policy` | `"retention_decay"` | — | — | Eviction algorithm. `"retention_decay"` is the only supported value. | | `predictive_intents` | `3` | — | — | Reserved for future predictive-intent recall. Currently accepted but ignored. | | `auto_extract_per_turn` | `true` | — | — | Extract atoms after every user message. | diff --git a/internal/memory/extended/config.go b/internal/memory/extended/config.go index 5cc93a00..a62589a8 100644 --- a/internal/memory/extended/config.go +++ b/internal/memory/extended/config.go @@ -26,6 +26,7 @@ type Config struct { MemoryBudgetChars int `json:"memory_budget_chars,omitempty"` DecayHalfLifeDays int `json:"decay_half_life_days,omitempty"` QuarantineTTLDays int `json:"quarantine_ttl_days,omitempty"` + EphemeralTTLDays int `json:"ephemeral_ttl_days,omitempty"` // TTL for ephemeral atom classes (intent/goal/error/question/file); 0 = default EvictionPolicy string `json:"eviction_policy,omitempty"` PredictiveIntents int `json:"predictive_intents,omitempty"` AutoExtractPerTurn *bool `json:"auto_extract_per_turn,omitempty"` @@ -86,6 +87,7 @@ func DefaultConfig() Config { MemoryBudgetChars: 2000, DecayHalfLifeDays: 30, QuarantineTTLDays: 7, + EphemeralTTLDays: DefaultEphemeralTTLDays, EvictionPolicy: "retention_decay", PredictiveIntents: 3, AutoExtractPerTurn: boolPtr(true), diff --git a/internal/memory/extended/extractor.go b/internal/memory/extended/extractor.go index 0c8628dd..478d3e05 100644 --- a/internal/memory/extended/extractor.go +++ b/internal/memory/extended/extractor.go @@ -49,9 +49,58 @@ Rules: - Do NOT extract ephemeral details specific only to this message. - If nothing durable is present, return an empty array. +Do NOT extract release-specific or bookkeeping facts — they rot immediately: +- Session IDs, turn numbers, timestamps, file paths, commit hashes. +- Version tags, PR numbers, CI run statuses ("tag 1.14.8", "PR #45", "merged as 768d380"). +- A session ID like "20260918-3e4cb01f" is provenance, never content. +- Statements about this memory system itself (pending reviews, stored atoms). +- Restatements of something already durable — generalize instead. + +Examples of REJECTS (do not emit these): + "User said merge after CI passes (turn 3, session 20260918-…)" -> REJECT (provenance in text) + "The correct version tag is 1.14.8" -> REJECT (release-ephemeral) + "Consume pending_review entry 555af9bf" -> REJECT (self-referential bookkeeping) + +Examples of ACCEPTS: + "User requires CI to pass before any merge" -> generalizes across projects + "User prefers concise answers" -> durable preference + Output ONLY a JSON array. Example: [{"text":"User prefers concise answers","type":"preference","confidence":0.9}]` +// qualityRules match atom text that violates the extractor quality +// contract: provenance tokens (session IDs, turn numbers), release +// ephemera (PR numbers, commit hashes, version tags), and self-referential +// bookkeeping. Each pattern is deliberately anchored to its noise shape to +// avoid false positives on legitimate atoms that merely contain digits. +var qualityRules = []struct { + name string + re *regexp.Regexp +}{ + {"session_id", regexp.MustCompile(`\bsession\s+[0-9]{8}-[0-9a-f]{4,}`)}, + {"turn_number", regexp.MustCompile(`\bturn\s+[0-9]{1,3}\b`)}, + {"pr_number", regexp.MustCompile(`(?i)\bpr\s+#[0-9]{1,6}\b`)}, + {"commit_hash", regexp.MustCompile(`\b[0-9a-f]{7,40}\b.*\b(?:merged|commit|squash)`)}, + {"commit_hash_merged", regexp.MustCompile(`\b(?:merged|squash-merged|commit)\s+(?:as\s+)?[0-9a-f]{7,40}\b`)}, + {"version_tag", regexp.MustCompile(`(?i)\b(?:version\s+)?tag\s+(?:is\s+|v)?[0-9]+\.[0-9]+`)}, + {"version_release", regexp.MustCompile(`\brelease\s+v?[0-9]+\.[0-9]+`)}, + {"pending_review_ref", regexp.MustCompile(`pending_review`)}, + {"already_stored", regexp.MustCompile(`(?i)already stored`)}, +} + +// qualityViolation reports whether atom text violates the extractor +// quality contract (provenance-in-text, release ephemera, or +// self-referential bookkeeping). It is the mechanism behind the prompt's +// negative examples: the prompt nudges the model, this filter enforces. +func qualityViolation(text string) bool { + for _, r := range qualityRules { + if r.re.MatchString(text) { + return true + } + } + return false +} + // untrustedRe matches nonce'd untrusted content wrappers so they can be // stripped before extraction. var untrustedRe = regexp.MustCompile(`(?s)]*\s+source="[^"]*"\s*>.*?]*>`) @@ -158,6 +207,45 @@ func normalizeAtomText(text string) string { // their retention score. Explicit LLM-provided values in (0,1] are kept. const defaultExtractionConfidence = 0.7 +// ExtractionTypeQuota caps atoms minted per atom type in one extraction +// run, so a verbose model cannot fill the store with one class of atom. +const ExtractionTypeQuota = 3 + +// ExtractionRunCap caps total atoms minted in one extraction run. +const ExtractionRunCap = 8 + +// applyExtractionQuotas quality-ranks candidate atoms and trims them to +// the per-type quota and the overall run cap. Ranking is by confidence, +// then stable order (first-seen wins ties). +func applyExtractionQuotas(atoms []MemoryAtom) []MemoryAtom { + if len(atoms) <= ExtractionRunCap && len(atoms) <= ExtractionTypeQuota { + return atoms + } + // Stable sort by confidence descending. + idx := make([]int, len(atoms)) + for i := range idx { + idx[i] = i + } + for i := 1; i < len(idx); i++ { + for j := i; j > 0 && atoms[idx[j]].Confidence > atoms[idx[j-1]].Confidence; j-- { + idx[j], idx[j-1] = idx[j-1], idx[j] + } + } + perType := make(map[string]int, len(atoms)) + out := make([]MemoryAtom, 0, ExtractionRunCap) + for _, i := range idx { + if len(out) >= ExtractionRunCap { + break + } + if perType[atoms[i].Type] >= ExtractionTypeQuota { + continue + } + perType[atoms[i].Type]++ + out = append(out, atoms[i]) + } + return out +} + // Extract atoms from text. Returns nil if the LLM is unavailable, the output // is unparseable, or no atoms are found. Extracted atoms are sourced from the // user ("user_said"). @@ -208,6 +296,12 @@ func (e *Extractor) Extract(ctx context.Context, text string) ([]MemoryAtom, err if txt == "" { continue } + // Quality contract: drop atoms whose text embeds provenance or + // release ephemera. The prompt nudges; this filter enforces. + if qualityViolation(txt) { + log.Printf("extended memory: dropped atom violating quality contract: %.80s", txt) + continue + } typ := r.Type if !validType(typ) { typ = TypeObservation @@ -230,7 +324,7 @@ func (e *Extractor) Extract(ctx context.Context, text string) ([]MemoryAtom, err Confidence: conf, }) } - return atoms, nil + return applyExtractionQuotas(atoms), nil } func validType(t string) bool { diff --git a/internal/memory/extended/extractor_quality_test.go b/internal/memory/extended/extractor_quality_test.go new file mode 100644 index 00000000..0aa4ada9 --- /dev/null +++ b/internal/memory/extended/extractor_quality_test.go @@ -0,0 +1,88 @@ +package extended + +import ( + "context" + "strings" + "testing" +) + +// TestQualityViolationDetectsProvenanceInText pins the write-time quality +// validator: atom text that embeds provenance tokens (session IDs, turn +// numbers) or release ephemera (PR numbers, commit hashes, version tags) +// must be flagged so the extractor can drop it. +func TestQualityViolationDetectsProvenanceInText(t *testing.T) { + cases := []struct { + name string + text string + want bool + }{ + {"session id", "User confirmed merge in session 20260918-3e4cb01f", true}, + {"turn number", "User said merge after CI (turn 3)", true}, + {"turn number bare", "decided at turn 12", true}, + {"pr number", "PR #45 was merged", true}, + {"pr number lowercase", "pr #45 was merged", true}, + {"commit hash", "squash-merged as 768d380 on main", true}, + {"version tag", "the correct version tag is 1.14.8", true}, + {"version tag v prefix", "release v1.42.3 shipped", true}, + {"pending_review self-reference", "consume/resolve pending_review entries 555af9bf", true}, + {"already stored restatement", "already stored, no change: user prefers concise answers", true}, + + {"clean preference", "User prefers concise answers", false}, + {"clean convention", "User requires CI checks to pass before any merge", false}, + {"clean fact", "User maintains odek under the BackendStack21 organization", false}, + {"number that is not a tag", "User keeps sub-agent concurrency capped at 2", false}, + {"hash-like word", "User likes the hashing approach", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := qualityViolation(tc.text) + if got != tc.want { + t.Errorf("qualityViolation(%q) = %v, want %v", tc.text, got, tc.want) + } + }) + } +} + +// TestExtractorDropsProvenanceAtoms pins the extractor contract: even when +// the LLM returns atoms with provenance or release ephemera baked into the +// text, Extract must not emit them. The prompt is a mitigation, not the +// mechanism — this filter is. +func TestExtractorDropsProvenanceAtoms(t *testing.T) { + resp := `[` + strings.Join([]string{ + `{"text":"User confirmed merge in session 20260918-3e4cb01f","type":"fact","confidence":0.9}`, + `{"text":"PR #45 was squash-merged as 768d380","type":"fact","confidence":0.8}`, + `{"text":"The correct version tag is 1.14.8","type":"fact","confidence":0.9}`, + `{"text":"Consume pending_review entries 555af9bf","type":"goal","confidence":0.85}`, + `{"text":"User requires CI to pass before any merge","type":"convention","confidence":0.95}`, + }, ",") + `]` + llm := newMockLLM(resp) + ex := NewExtractor(llm, DefaultConfig()) + atoms, err := ex.Extract(context.Background(), "we merged it") + if err != nil { + t.Fatalf("Extract failed: %v", err) + } + if len(atoms) != 1 { + t.Fatalf("expected 1 surviving atom, got %d: %+v", len(atoms), atoms) + } + if !strings.Contains(atoms[0].Text, "CI to pass") { + t.Errorf("surviving atom should be the generalizing one, got %q", atoms[0].Text) + } +} + +// TestExtractionPromptBansNoise pins that the prompt itself carries the +// negative guidance (example-first rejects), so the model is nudged before +// the write-time filter has to drop anything. +func TestExtractionPromptBansNoise(t *testing.T) { + for _, want := range []string{ + "Do NOT extract", + "session ID", + "turn number", + "version", + "PR", + "REJECT", + } { + if !strings.Contains(extractionPrompt, want) { + t.Errorf("extractionPrompt missing negative-example guidance %q", want) + } + } +} diff --git a/internal/memory/extended/extractor_quota_test.go b/internal/memory/extended/extractor_quota_test.go new file mode 100644 index 00000000..0d4c9ec0 --- /dev/null +++ b/internal/memory/extended/extractor_quota_test.go @@ -0,0 +1,79 @@ +package extended + +import ( + "context" + "strings" + "testing" +) + +// TestExtractorEnforcesTypeQuotas pins that a single extraction run cannot +// mint an unbounded number of atoms of one type: quality-ranked quota per +// type plus an overall per-run cap. A model that emits 6 goal atoms in one +// run must not store them all. +func TestExtractorEnforcesTypeQuotas(t *testing.T) { + mk := func(typ string) string { + return `{"text":"User goal ` + typ + ` number placeholder unique","type":"` + typ + `","confidence":0.9}` + } + var items []string + for i := 0; i < 6; i++ { + items = append(items, strings.Replace(mk("goal"), "placeholder", string(rune('a'+i)), 1)) + } + for i := 0; i < 6; i++ { + items = append(items, strings.Replace(mk("fact"), "placeholder", string(rune('a'+i)), 1)) + } + resp := `[` + strings.Join(items, ",") + `]` + llm := newMockLLM(resp) + ex := NewExtractor(llm, DefaultConfig()) + atoms, err := ex.Extract(context.Background(), "lots of things") + if err != nil { + t.Fatalf("Extract failed: %v", err) + } + goals, facts := 0, 0 + for _, a := range atoms { + switch a.Type { + case TypeGoal: + goals++ + case TypeFact: + facts++ + } + } + if goals > ExtractionTypeQuota { + t.Errorf("goal atoms = %d, want <= %d", goals, ExtractionTypeQuota) + } + if facts > ExtractionTypeQuota { + t.Errorf("fact atoms = %d, want <= %d", facts, ExtractionTypeQuota) + } + if len(atoms) > ExtractionRunCap { + t.Errorf("total atoms = %d, want <= %d", len(atoms), ExtractionRunCap) + } +} + +// TestExtractorTypeQuotaKeepsHighestConfidence pins that quota trimming is +// quality-ranked: when over quota, the highest-confidence atoms survive. +func TestExtractorTypeQuotaKeepsHighestConfidence(t *testing.T) { + resp := `[` + strings.Join([]string{ + `{"text":"low confidence goal","type":"goal","confidence":0.3}`, + `{"text":"high confidence goal","type":"goal","confidence":0.95}`, + `{"text":"mid confidence goal","type":"goal","confidence":0.6}`, + `{"text":"another high goal","type":"goal","confidence":0.9}`, + }, ",") + `]` + llm := newMockLLM(resp) + ex := NewExtractor(llm, DefaultConfig()) + atoms, err := ex.Extract(context.Background(), "goals") + if err != nil { + t.Fatalf("Extract failed: %v", err) + } + if len(atoms) != ExtractionTypeQuota { + t.Fatalf("expected %d atoms after quota, got %d", ExtractionTypeQuota, len(atoms)) + } + kept := map[string]bool{} + for _, a := range atoms { + kept[a.Text] = true + } + if !kept["high confidence goal"] || !kept["another high goal"] { + t.Errorf("quota must keep the highest-confidence atoms, kept %v", kept) + } + if kept["low confidence goal"] { + t.Errorf("quota must drop the lowest-confidence atom first, kept %v", kept) + } +} diff --git a/internal/memory/extended/recall.go b/internal/memory/extended/recall.go index c6ac2a06..872c059d 100644 --- a/internal/memory/extended/recall.go +++ b/internal/memory/extended/recall.go @@ -85,6 +85,7 @@ func (r *Recall) Query(ctx context.Context, query string, recent []string, state log.Printf("extended memory: recall query failed: %v", err) return "", err } + res = filterExpiredAtoms(res, r.cfg.EphemeralTTLDays) if len(res) == 0 { return "", nil } diff --git a/internal/memory/extended/ttl.go b/internal/memory/extended/ttl.go new file mode 100644 index 00000000..4ffe310f --- /dev/null +++ b/internal/memory/extended/ttl.go @@ -0,0 +1,46 @@ +package extended + +import "time" + +// ephemeralTypes are atom classes whose value decays with time: intents and +// goals expire once achieved, errors and questions once resolved, and file +// references rot as the codebase moves. Durable classes (preference, +// convention, fact, decision) carry no TTL by design. +var ephemeralTypes = map[string]bool{ + TypeIntent: true, + TypeGoal: true, + TypeError: true, + TypeQuestion: true, + TypeFile: true, +} + +// DefaultEphemeralTTLDays is the fallback TTL for ephemeral atom classes +// when the config does not set one. +const DefaultEphemeralTTLDays = 14 + +// AtomExpired reports whether an ephemeral-class atom has outlived its TTL +// and must stop being recalled. Pinned atoms never expire; durable classes +// never expire via TTL (they leave through consolidation/eviction instead). +func AtomExpired(atom MemoryAtom, ttlDays int, now time.Time) bool { + if atom.Pin || !ephemeralTypes[atom.Type] { + return false + } + if ttlDays <= 0 { + ttlDays = DefaultEphemeralTTLDays + } + deadline := atom.CreatedAt.AddDate(0, 0, ttlDays) + return !now.Before(deadline) +} + +// filterExpiredAtoms removes TTL-expired ephemeral atoms from a recall set. +func filterExpiredAtoms(atoms []MemoryAtom, ttlDays int) []MemoryAtom { + now := time.Now().UTC() + out := atoms[:0] + for _, a := range atoms { + if AtomExpired(a, ttlDays, now) { + continue + } + out = append(out, a) + } + return out +} diff --git a/internal/memory/extended/ttl_test.go b/internal/memory/extended/ttl_test.go new file mode 100644 index 00000000..85340888 --- /dev/null +++ b/internal/memory/extended/ttl_test.go @@ -0,0 +1,53 @@ +package extended + +import ( + "testing" + "time" +) + +// TestAtomExpired pins the per-class TTL: ephemeral atom types (intent, +// goal, error, question, file) expire after the configured TTL and stop +// being recalled, while durable types (preference, convention, fact, +// decision) never TTL out and pinned atoms are always exempt. +func TestAtomExpired(t *testing.T) { + now := time.Now().UTC() + old := now.AddDate(0, 0, -30) + cases := []struct { + name string + atom MemoryAtom + want bool + }{ + {"old error", MemoryAtom{Type: TypeError, CreatedAt: old}, true}, + {"old goal", MemoryAtom{Type: TypeGoal, CreatedAt: old}, true}, + {"old question", MemoryAtom{Type: TypeQuestion, CreatedAt: old}, true}, + {"old intent", MemoryAtom{Type: TypeIntent, CreatedAt: old}, true}, + {"old file", MemoryAtom{Type: TypeFile, CreatedAt: old}, true}, + {"old preference", MemoryAtom{Type: TypePreference, CreatedAt: old}, false}, + {"old convention", MemoryAtom{Type: TypeConvention, CreatedAt: old}, false}, + {"old fact", MemoryAtom{Type: TypeFact, CreatedAt: old}, false}, + {"old decision", MemoryAtom{Type: TypeDecision, CreatedAt: old}, false}, + {"pinned error", MemoryAtom{Type: TypeError, CreatedAt: old, Pin: true}, false}, + {"fresh error", MemoryAtom{Type: TypeError, CreatedAt: now}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := AtomExpired(tc.atom, 14, now); got != tc.want { + t.Errorf("AtomExpired(%s) = %v, want %v", tc.name, got, tc.want) + } + }) + } +} + +// TestRecallSkipsExpiredEphemeralAtoms pins that expired ephemeral atoms +// are not injected into the recall context. +func TestRecallSkipsExpiredEphemeralAtoms(t *testing.T) { + old := time.Now().UTC().AddDate(0, 0, -30) + atoms := []MemoryAtom{ + {ID: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d1", Text: "stale error", Type: TypeError, CreatedAt: old}, + {ID: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d2", Text: "durable convention", Type: TypeConvention, CreatedAt: old}, + } + got := filterExpiredAtoms(atoms, 14) + if len(got) != 1 || got[0].Text != "durable convention" { + t.Fatalf("expected only durable atom to survive, got %+v", got) + } +} From e308b5b9383f457bec908b1a544f6906a1b4454a Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:42:28 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(memory):=20address=20adversarial=20revi?= =?UTF-8?q?ew=20=E2=80=94=20MergeConfig=20branch,=20anchored=20regexes,=20?= =?UTF-8?q?reviewable=20drops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MergeConfig now honors ephemeral_ttl_days (was silently ignored). - qualityRules anchored to provenance shapes: turn numbers require a following comma/paren (prose like 'per turn 100 requests' survives), commit hashes require a digit, bare vX.Y.Z semver added as release ephemera (Go 1.24-style two-component versions stay safe). - Drops log the matched rule name for reviewability. - Legacy atoms with zero CreatedAt never TTL-expire. - filterExpiredAtoms returns a fresh slice instead of aliasing the input. --- internal/memory/extended/config.go | 3 ++ internal/memory/extended/extractor.go | 31 ++++++++++++------- .../memory/extended/extractor_quality_test.go | 7 +++-- internal/memory/extended/ttl.go | 7 ++++- internal/memory/extended/ttl_test.go | 1 + 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/internal/memory/extended/config.go b/internal/memory/extended/config.go index a62589a8..50042eb7 100644 --- a/internal/memory/extended/config.go +++ b/internal/memory/extended/config.go @@ -146,6 +146,9 @@ func Resolve(cfg Config) Config { if cfg.QuarantineTTLDays > 0 { def.QuarantineTTLDays = cfg.QuarantineTTLDays } + if cfg.EphemeralTTLDays > 0 { + def.EphemeralTTLDays = cfg.EphemeralTTLDays + } if cfg.EvictionPolicy != "" { def.EvictionPolicy = cfg.EvictionPolicy } diff --git a/internal/memory/extended/extractor.go b/internal/memory/extended/extractor.go index 478d3e05..dae391b1 100644 --- a/internal/memory/extended/extractor.go +++ b/internal/memory/extended/extractor.go @@ -78,27 +78,34 @@ var qualityRules = []struct { re *regexp.Regexp }{ {"session_id", regexp.MustCompile(`\bsession\s+[0-9]{8}-[0-9a-f]{4,}`)}, - {"turn_number", regexp.MustCompile(`\bturn\s+[0-9]{1,3}\b`)}, + {"turn_number", regexp.MustCompile(`\bturn\s+[0-9]{1,3}\s*[,)]`)}, {"pr_number", regexp.MustCompile(`(?i)\bpr\s+#[0-9]{1,6}\b`)}, - {"commit_hash", regexp.MustCompile(`\b[0-9a-f]{7,40}\b.*\b(?:merged|commit|squash)`)}, - {"commit_hash_merged", regexp.MustCompile(`\b(?:merged|squash-merged|commit)\s+(?:as\s+)?[0-9a-f]{7,40}\b`)}, + {"commit_hash", regexp.MustCompile(`\b[0-9a-f]*[0-9][0-9a-f]{6,39}\b.*\b(?:merged|commit|squash)`)}, + {"commit_hash_merged", regexp.MustCompile(`\b(?:merged|squash-merged|commit)\s+(?:as\s+)?[0-9a-f]*[0-9][0-9a-f]{6,39}\b`)}, {"version_tag", regexp.MustCompile(`(?i)\b(?:version\s+)?tag\s+(?:is\s+|v)?[0-9]+\.[0-9]+`)}, {"version_release", regexp.MustCompile(`\brelease\s+v?[0-9]+\.[0-9]+`)}, + {"semver", regexp.MustCompile(`\bv[0-9]+\.[0-9]+\.[0-9]+\b`)}, {"pending_review_ref", regexp.MustCompile(`pending_review`)}, {"already_stored", regexp.MustCompile(`(?i)already stored`)}, } -// qualityViolation reports whether atom text violates the extractor -// quality contract (provenance-in-text, release ephemera, or -// self-referential bookkeeping). It is the mechanism behind the prompt's -// negative examples: the prompt nudges the model, this filter enforces. -func qualityViolation(text string) bool { +// qualityViolation reports which rule (if any) an atom's text violates. +// The returned rule name makes drops reviewable in logs. +func qualityViolationRule(text string) (string, bool) { for _, r := range qualityRules { if r.re.MatchString(text) { - return true + return r.name, true } } - return false + return "", false +} + +// qualityViolation reports whether atom text violates the extractor +// quality contract (provenance-in-text, release ephemera, or +// self-referential bookkeeping). +func qualityViolation(text string) bool { + _, ok := qualityViolationRule(text) + return ok } // untrustedRe matches nonce'd untrusted content wrappers so they can be @@ -298,8 +305,8 @@ func (e *Extractor) Extract(ctx context.Context, text string) ([]MemoryAtom, err } // Quality contract: drop atoms whose text embeds provenance or // release ephemera. The prompt nudges; this filter enforces. - if qualityViolation(txt) { - log.Printf("extended memory: dropped atom violating quality contract: %.80s", txt) + if rule, bad := qualityViolationRule(txt); bad { + log.Printf("extended memory: dropped atom violating quality contract (rule %s): %.80s", rule, txt) continue } typ := r.Type diff --git a/internal/memory/extended/extractor_quality_test.go b/internal/memory/extended/extractor_quality_test.go index 0aa4ada9..4d19a441 100644 --- a/internal/memory/extended/extractor_quality_test.go +++ b/internal/memory/extended/extractor_quality_test.go @@ -17,13 +17,16 @@ func TestQualityViolationDetectsProvenanceInText(t *testing.T) { want bool }{ {"session id", "User confirmed merge in session 20260918-3e4cb01f", true}, - {"turn number", "User said merge after CI (turn 3)", true}, - {"turn number bare", "decided at turn 12", true}, + {"turn number parenthetical", "User said merge after CI (turn 3)", true}, + {"turn number comma", "decided, at turn 12, to proceed", true}, + {"turn prose not provenance", "per turn 100 requests are allowed", false}, {"pr number", "PR #45 was merged", true}, {"pr number lowercase", "pr #45 was merged", true}, {"commit hash", "squash-merged as 768d380 on main", true}, {"version tag", "the correct version tag is 1.14.8", true}, {"version tag v prefix", "release v1.42.3 shipped", true}, + {"semver bare", "shipped v1.42.1 yesterday", true}, + {"go version not a tag", "User works with Go 1.24", false}, {"pending_review self-reference", "consume/resolve pending_review entries 555af9bf", true}, {"already stored restatement", "already stored, no change: user prefers concise answers", true}, diff --git a/internal/memory/extended/ttl.go b/internal/memory/extended/ttl.go index 4ffe310f..c9fe440e 100644 --- a/internal/memory/extended/ttl.go +++ b/internal/memory/extended/ttl.go @@ -25,6 +25,11 @@ func AtomExpired(atom MemoryAtom, ttlDays int, now time.Time) bool { if atom.Pin || !ephemeralTypes[atom.Type] { return false } + if atom.CreatedAt.IsZero() { + // Legacy atoms without a creation time are treated as fresh so a + // TTL never silently wipes them. + return false + } if ttlDays <= 0 { ttlDays = DefaultEphemeralTTLDays } @@ -35,7 +40,7 @@ func AtomExpired(atom MemoryAtom, ttlDays int, now time.Time) bool { // filterExpiredAtoms removes TTL-expired ephemeral atoms from a recall set. func filterExpiredAtoms(atoms []MemoryAtom, ttlDays int) []MemoryAtom { now := time.Now().UTC() - out := atoms[:0] + out := make([]MemoryAtom, 0, len(atoms)) for _, a := range atoms { if AtomExpired(a, ttlDays, now) { continue diff --git a/internal/memory/extended/ttl_test.go b/internal/memory/extended/ttl_test.go index 85340888..35438683 100644 --- a/internal/memory/extended/ttl_test.go +++ b/internal/memory/extended/ttl_test.go @@ -28,6 +28,7 @@ func TestAtomExpired(t *testing.T) { {"old decision", MemoryAtom{Type: TypeDecision, CreatedAt: old}, false}, {"pinned error", MemoryAtom{Type: TypeError, CreatedAt: old, Pin: true}, false}, {"fresh error", MemoryAtom{Type: TypeError, CreatedAt: now}, false}, + {"zero created_at legacy", MemoryAtom{Type: TypeError}, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { From ae814c07937c1cdb0abad626ba49779add6a54ca Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:31:04 +0200 Subject: [PATCH 3/3] test(memory): cover TTL fallback and quota passthrough paths --- internal/memory/extended/extractor_quota_test.go | 13 +++++++++++++ internal/memory/extended/ttl_test.go | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/internal/memory/extended/extractor_quota_test.go b/internal/memory/extended/extractor_quota_test.go index 0d4c9ec0..a93abaf0 100644 --- a/internal/memory/extended/extractor_quota_test.go +++ b/internal/memory/extended/extractor_quota_test.go @@ -48,6 +48,19 @@ func TestExtractorEnforcesTypeQuotas(t *testing.T) { } } +// TestApplyExtractionQuotasSmallBatchPassthrough pins that a batch within +// both caps passes through untouched, in original order. +func TestApplyExtractionQuotasSmallBatchPassthrough(t *testing.T) { + atoms := []MemoryAtom{ + {Text: "a", Type: TypeFact, Confidence: 0.1}, + {Text: "b", Type: TypeFact, Confidence: 0.9}, + } + got := applyExtractionQuotas(atoms) + if len(got) != 2 || got[0].Text != "a" || got[1].Text != "b" { + t.Errorf("small batch must pass through in order, got %+v", got) + } +} + // TestExtractorTypeQuotaKeepsHighestConfidence pins that quota trimming is // quality-ranked: when over quota, the highest-confidence atoms survive. func TestExtractorTypeQuotaKeepsHighestConfidence(t *testing.T) { diff --git a/internal/memory/extended/ttl_test.go b/internal/memory/extended/ttl_test.go index 35438683..88452778 100644 --- a/internal/memory/extended/ttl_test.go +++ b/internal/memory/extended/ttl_test.go @@ -39,6 +39,20 @@ func TestAtomExpired(t *testing.T) { } } +// TestAtomExpiredTTLDaysFallback pins that a zero or negative TTL days +// value falls back to the default rather than disabling or inverting the +// TTL. +func TestAtomExpiredTTLDaysFallback(t *testing.T) { + old := time.Now().UTC().AddDate(0, 0, -(DefaultEphemeralTTLDays + 1)) + atom := MemoryAtom{Type: TypeError, CreatedAt: old} + if !AtomExpired(atom, 0, time.Now().UTC()) { + t.Error("ttlDays=0 must fall back to the default TTL, expiring an old ephemeral atom") + } + if !AtomExpired(atom, -5, time.Now().UTC()) { + t.Error("negative ttlDays must fall back to the default TTL") + } +} + // TestRecallSkipsExpiredEphemeralAtoms pins that expired ephemeral atoms // are not injected into the recall context. func TestRecallSkipsExpiredEphemeralAtoms(t *testing.T) {