|
| 1 | +package memory |
| 2 | + |
| 3 | +import ( |
| 4 | + "regexp" |
| 5 | + "strings" |
| 6 | + "unicode/utf8" |
| 7 | +) |
| 8 | + |
| 9 | +// maxBufferSummaryRunes bounds the length of a single buffer turn-summary. |
| 10 | +// Raised from the old ~100-byte cut so that, after filler-stripping, one or two |
| 11 | +// substantive sentences survive. Total injected ≈ BufferLines × this; at the |
| 12 | +// default 20 lines that is ~4 KB worst case — negligible against the prompt. |
| 13 | +const maxBufferSummaryRunes = 200 |
| 14 | + |
| 15 | +// codePlaceholder is substituted when a turn is nothing but a code block, so the |
| 16 | +// buffer line is never blank. |
| 17 | +const codePlaceholder = "[code]" |
| 18 | + |
| 19 | +var ( |
| 20 | + // Fenced code blocks: ```lang\n ... ``` (dot-all, non-greedy). |
| 21 | + reCodeFence = regexp.MustCompile("(?s)```.*?```") |
| 22 | + // Inline code: `text` -> text (keep inner content). |
| 23 | + reInlineCode = regexp.MustCompile("`([^`]*)`") |
| 24 | + // Markdown images  -> alt (run before links). |
| 25 | + reMdImage = regexp.MustCompile(`!\[([^\]]*)\]\([^)]*\)`) |
| 26 | + // Markdown links [text](url) -> text. |
| 27 | + reMdLink = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`) |
| 28 | + // Line-leading markers: heading #, blockquote >, bullets -/*/+, numbered N. |
| 29 | + reLineMarker = regexp.MustCompile(`(?m)^[ \t]*(?:#{1,6}|>+|[-*+]|\d+\.)[ \t]+`) |
| 30 | + // Bold/italic emphasis pairs. Single * / _ are left alone to avoid harming |
| 31 | + // snake_case identifiers and lone math/glob asterisks. |
| 32 | + reBold = regexp.MustCompile(`(\*\*|__)([^*_]+)(\*\*|__)`) |
| 33 | + // Leading acknowledgement clause (pure filler), up to its sentence end. |
| 34 | + // Note: "let me ..." / "i'll start ..." are actions, deliberately NOT here. |
| 35 | + reFiller = regexp.MustCompile(`(?i)^(?:sure|okay|ok|got it|certainly|of course|alright|no problem|sounds good|absolutely|great|happy to help|glad to help|i'?ll help|i can help)\b[^.!?]*[.!?]\s+`) |
| 36 | +) |
| 37 | + |
| 38 | +// summarizeForBuffer produces a deterministic, no-LLM, rune-safe single-line |
| 39 | +// excerpt of raw turn text for the tier-2 buffer. It strips code/markdown noise, |
| 40 | +// collapses whitespace, drops a leading filler clause when substance remains, |
| 41 | +// and cuts at a sentence/word boundary within maxBufferSummaryRunes. |
| 42 | +// |
| 43 | +// It is invoked only on WRITE (MemoryManager.AppendBuffer). It must never run on |
| 44 | +// already-stored buffer lines (RestoreBuffer bypasses it) — re-running it on a |
| 45 | +// formatted "HH:MM role msg" line would corrupt the persisted summary. |
| 46 | +func summarizeForBuffer(text string) string { |
| 47 | + // 1. Remove fenced code blocks; remember whether we saw one. |
| 48 | + hadCode := reCodeFence.MatchString(text) |
| 49 | + text = reCodeFence.ReplaceAllString(text, " ") |
| 50 | + |
| 51 | + // 2. Inline code -> inner text. |
| 52 | + text = reInlineCode.ReplaceAllString(text, "$1") |
| 53 | + |
| 54 | + // 3. Images/links -> their visible text. |
| 55 | + text = reMdImage.ReplaceAllString(text, "$1") |
| 56 | + text = reMdLink.ReplaceAllString(text, "$1") |
| 57 | + |
| 58 | + // 4. Line-leading markers and bold/italic emphasis. |
| 59 | + text = reLineMarker.ReplaceAllString(text, "") |
| 60 | + text = reBold.ReplaceAllString(text, "$2") |
| 61 | + |
| 62 | + // 5. Collapse every whitespace run (incl. newlines/tabs) to a single space. |
| 63 | + text = strings.Join(strings.Fields(text), " ") |
| 64 | + |
| 65 | + if text == "" { |
| 66 | + if hadCode { |
| 67 | + return codePlaceholder |
| 68 | + } |
| 69 | + return "" |
| 70 | + } |
| 71 | + |
| 72 | + // 6. Drop a single leading filler clause, but only if substance remains. |
| 73 | + if loc := reFiller.FindStringIndex(text); loc != nil { |
| 74 | + if rest := strings.TrimSpace(text[loc[1]:]); rest != "" { |
| 75 | + text = rest |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + // 7. Excerpt to the rune cap at a sentence/word boundary. |
| 80 | + return excerptRunes(text, maxBufferSummaryRunes) |
| 81 | +} |
| 82 | + |
| 83 | +// excerptRunes returns s unchanged if it fits within max runes; otherwise it |
| 84 | +// cuts at the last sentence terminator (.!?) before the cap, else the last |
| 85 | +// space, else hard-cuts at the cap — always on a rune boundary — and appends an |
| 86 | +// ellipsis. Never splits a multibyte rune. |
| 87 | +func excerptRunes(s string, max int) string { |
| 88 | + if utf8.RuneCountInString(s) <= max { |
| 89 | + return s |
| 90 | + } |
| 91 | + runes := []rune(s) |
| 92 | + window := runes[:max] |
| 93 | + |
| 94 | + cut := -1 |
| 95 | + for i, r := range window { |
| 96 | + if r == '.' || r == '!' || r == '?' { |
| 97 | + cut = i + 1 // include the terminator |
| 98 | + } |
| 99 | + } |
| 100 | + if cut <= 0 { |
| 101 | + // No sentence end; fall back to the last word boundary. |
| 102 | + for i := len(window) - 1; i >= 0; i-- { |
| 103 | + if window[i] == ' ' { |
| 104 | + cut = i |
| 105 | + break |
| 106 | + } |
| 107 | + } |
| 108 | + } |
| 109 | + if cut <= 0 { |
| 110 | + // Single very long token: hard-cut at the cap. |
| 111 | + cut = max |
| 112 | + } |
| 113 | + |
| 114 | + return strings.TrimSpace(string(window[:cut])) + "…" |
| 115 | +} |
0 commit comments