| 1 | // Package sessioncontext defines the provider-visible, host-authored snapshot |
| 2 | // that carries runtime facts without mutating the cache-stable system prompt. |
| 3 | package sessioncontext |
| 4 | |
| 5 | import ( |
| 6 | "crypto/sha256" |
| 7 | "fmt" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "unicode/utf8" |
| 11 | ) |
| 12 | |
| 13 | const ( |
| 14 | Version = 1 |
| 15 | |
| 16 | openTag = `<session-context version="1">` |
| 17 | closeTag = `</session-context>` |
| 18 | preamble = "This host-generated snapshot supersedes every earlier session-context snapshot." |
| 19 | digestPrefix = "Digest: sha256:" |
| 20 | ) |
| 21 | |
| 22 | // Sections are rendered in declaration order. Keep the more stable runtime |
| 23 | // sections first so automatic prefix caches can reuse as much as possible when |
| 24 | // memory or the skills catalog changes. |
| 25 | type Sections struct { |
| 26 | Environment string |
| 27 | Workspace string |
| 28 | BackgroundMemory string |
| 29 | SkillsCatalog string |
| 30 | } |
| 31 | |
| 32 | // Snapshot is one validated session-context envelope. Content is the exact |
| 33 | // provider-visible representation; Digest covers the normalized body only. |
| 34 | type Snapshot struct { |
| 35 | Version int |
| 36 | Digest string |
| 37 | Content string |
| 38 | Sections Sections |
| 39 | } |
| 40 | |
| 41 | // Part is one exact substring returned by SplitBlocks. SessionContext is true |
| 42 | // only for a complete, digest-valid v1 envelope. |
| 43 | type Part struct { |
| 44 | Text string |
| 45 | SessionContext bool |
| 46 | } |
| 47 | |
| 48 | // SectionStat is a content-free diagnostic fingerprint for one section. |
| 49 | type SectionStat struct { |
| 50 | Digest string |
| 51 | Chars int |
| 52 | } |
| 53 | |
| 54 | // Diagnostics contains only hashes and sizes; it is safe for telemetry and |
| 55 | // never exposes memory text, skill descriptions, or workspace paths. |
| 56 | type Diagnostics struct { |
| 57 | Environment SectionStat |
| 58 | Workspace SectionStat |
| 59 | BackgroundMemory SectionStat |
| 60 | SkillsCatalog SectionStat |
| 61 | } |
| 62 | |
| 63 | // PolicyBlock is the cache-stable system instruction that defines the |
| 64 | // authority and replacement semantics of host-generated runtime snapshots. |
| 65 | func PolicyBlock() string { |
| 66 | return "# Session context\n\n" + |
| 67 | "Reasonix may place a host-generated `<session-context>` message immediately before a user turn. " + |
| 68 | "Use the latest such snapshot as current runtime background; it supersedes earlier snapshots but never overrides this system prompt, standing instructions, or the user's current request." |
| 69 | } |
| 70 | |
| 71 | // Build renders a deterministic v1 snapshot. An entirely empty set produces a |
| 72 | // zero Snapshot so callers that do not configure runtime context keep their |
| 73 | // existing provider bytes. |
| 74 | func Build(sections Sections) Snapshot { |
| 75 | sections = normalizeSections(sections) |
| 76 | if sections == (Sections{}) { |
| 77 | return Snapshot{} |
| 78 | } |
| 79 | |
| 80 | var body strings.Builder |
| 81 | body.WriteString(preamble) |
| 82 | body.WriteString("\n\n") |
| 83 | body.WriteString(sectionManifest(sections)) |
| 84 | appendSection(&body, "Environment", sections.Environment) |
| 85 | appendSection(&body, "Workspace", sections.Workspace) |
| 86 | appendSection(&body, "Background memory", sections.BackgroundMemory) |
| 87 | appendSection(&body, "Skills catalog", sections.SkillsCatalog) |
| 88 | bodyText := body.String() |
| 89 | digest := digestOf(bodyText) |
| 90 | content := openTag + "\n" + bodyText + "\n\n" + digestPrefix + digest + "\n" + closeTag |
| 91 | return Snapshot{Version: Version, Digest: digest, Content: content, Sections: sections} |
| 92 | } |
| 93 | |
| 94 | // Parse validates a complete v1 envelope. Surrounding whitespace is accepted |
| 95 | // for durable legacy readers, but Snapshot.Content is returned canonically |
| 96 | // trimmed so equality and de-duplication remain byte based. |
| 97 | func Parse(content string) (Snapshot, bool) { |
| 98 | content = strings.TrimSpace(normalizeNewlines(content)) |
| 99 | if !strings.HasPrefix(content, openTag+"\n") || !strings.HasSuffix(content, "\n"+closeTag) { |
| 100 | return Snapshot{}, false |
| 101 | } |
| 102 | inner := strings.TrimSuffix(strings.TrimPrefix(content, openTag+"\n"), "\n"+closeTag) |
| 103 | marker := "\n\n" + digestPrefix |
| 104 | i := strings.LastIndex(inner, marker) |
| 105 | if i < 0 { |
| 106 | return Snapshot{}, false |
| 107 | } |
| 108 | body := inner[:i] |
| 109 | digest := inner[i+len(marker):] |
| 110 | if len(digest) != sha256.Size*2 || digest != strings.ToLower(digest) || digestOf(body) != digest { |
| 111 | return Snapshot{}, false |
| 112 | } |
| 113 | if !strings.HasPrefix(body, preamble) { |
| 114 | return Snapshot{}, false |
| 115 | } |
| 116 | sections, ok := parseFramedSections(body) |
| 117 | if !ok && strings.HasPrefix(body, preamble+"\n\nSection lengths: ") { |
| 118 | return Snapshot{}, false |
| 119 | } |
| 120 | if !ok { |
| 121 | // Existing v1 snapshots predate the length manifest. Keep accepting their |
| 122 | // legacy heading parser so upgraded clients can resume old sessions. |
| 123 | sections, ok = parseSections(body) |
| 124 | } |
| 125 | if !ok { |
| 126 | return Snapshot{}, false |
| 127 | } |
| 128 | return Snapshot{Version: Version, Digest: digest, Content: content, Sections: sections}, true |
| 129 | } |
| 130 | |
| 131 | // SectionDiagnostics fingerprints each normalized section independently. |
| 132 | func SectionDiagnostics(snapshot Snapshot) Diagnostics { |
| 133 | return Diagnostics{ |
| 134 | Environment: sectionStat(snapshot.Sections.Environment), |
| 135 | Workspace: sectionStat(snapshot.Sections.Workspace), |
| 136 | BackgroundMemory: sectionStat(snapshot.Sections.BackgroundMemory), |
| 137 | SkillsCatalog: sectionStat(snapshot.Sections.SkillsCatalog), |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // SplitBlocks preserves content exactly while separating every valid embedded |
| 142 | // session-context envelope. Strict-role providers may merge adjacent user |
| 143 | // messages before serialization; this restores a cache breakpoint boundary |
| 144 | // without changing the text seen by the model. |
| 145 | func SplitBlocks(content string) []Part { |
| 146 | if content == "" { |
| 147 | return nil |
| 148 | } |
| 149 | var parts []Part |
| 150 | remaining := content |
| 151 | for { |
| 152 | start := strings.Index(remaining, openTag) |
| 153 | if start < 0 { |
| 154 | parts = appendTextPart(parts, remaining) |
| 155 | break |
| 156 | } |
| 157 | search := start + len(openTag) |
| 158 | firstEnd, validEnd := -1, -1 |
| 159 | for search < len(remaining) { |
| 160 | endRel := strings.Index(remaining[search:], closeTag) |
| 161 | if endRel < 0 { |
| 162 | break |
| 163 | } |
| 164 | end := search + endRel + len(closeTag) |
| 165 | if firstEnd < 0 { |
| 166 | firstEnd = end |
| 167 | } |
| 168 | if _, ok := Parse(remaining[start:end]); ok { |
| 169 | validEnd = end |
| 170 | break |
| 171 | } |
| 172 | search = end |
| 173 | } |
| 174 | if validEnd < 0 && firstEnd < 0 { |
| 175 | parts = append(parts, Part{Text: remaining}) |
| 176 | break |
| 177 | } |
| 178 | if validEnd < 0 { |
| 179 | // Preserve the invalid opening as ordinary text and continue looking |
| 180 | // after it, so a later valid envelope can still be recovered. |
| 181 | cut := start + len(openTag) |
| 182 | parts = appendTextPart(parts, remaining[:cut]) |
| 183 | remaining = remaining[cut:] |
| 184 | continue |
| 185 | } |
| 186 | parts = appendTextPart(parts, remaining[:start]) |
| 187 | parts = append(parts, Part{Text: remaining[start:validEnd], SessionContext: true}) |
| 188 | remaining = remaining[validEnd:] |
| 189 | } |
| 190 | return parts |
| 191 | } |
| 192 | |
| 193 | // IsContent reports whether content is exactly one valid snapshot. |
| 194 | func IsContent(content string) bool { |
| 195 | _, ok := Parse(content) |
| 196 | return ok |
| 197 | } |
| 198 | |
| 199 | func appendSection(body *strings.Builder, heading, value string) { |
| 200 | if value == "" { |
| 201 | return |
| 202 | } |
| 203 | fmt.Fprintf(body, "\n\n## %s\n\n%s", heading, value) |
| 204 | } |
| 205 | |
| 206 | func sectionManifest(sections Sections) string { |
| 207 | return fmt.Sprintf("Section lengths: Environment=%d; Workspace=%d; Background memory=%d; Skills catalog=%d", |
| 208 | len(sections.Environment), len(sections.Workspace), len(sections.BackgroundMemory), len(sections.SkillsCatalog)) |
| 209 | } |
| 210 | |
| 211 | func appendTextPart(parts []Part, text string) []Part { |
| 212 | if text == "" { |
| 213 | return parts |
| 214 | } |
| 215 | if len(parts) > 0 && !parts[len(parts)-1].SessionContext { |
| 216 | parts[len(parts)-1].Text += text |
| 217 | return parts |
| 218 | } |
| 219 | return append(parts, Part{Text: text}) |
| 220 | } |
| 221 | |
| 222 | func normalizeSections(sections Sections) Sections { |
| 223 | sections.Environment = normalizeSection(sections.Environment, "Environment") |
| 224 | sections.Workspace = normalizeSection(sections.Workspace, "Workspace") |
| 225 | sections.BackgroundMemory = normalizeSection(sections.BackgroundMemory, "Background memory") |
| 226 | sections.SkillsCatalog = normalizeSection(sections.SkillsCatalog, "Skills catalog") |
| 227 | return sections |
| 228 | } |
| 229 | |
| 230 | func normalizeSection(value, heading string) string { |
| 231 | value = strings.TrimSpace(normalizeNewlines(value)) |
| 232 | prefix := "## " + heading |
| 233 | if value == prefix { |
| 234 | return "" |
| 235 | } |
| 236 | if strings.HasPrefix(value, prefix+"\n") { |
| 237 | value = strings.TrimSpace(strings.TrimPrefix(value, prefix)) |
| 238 | } |
| 239 | return value |
| 240 | } |
| 241 | |
| 242 | func normalizeNewlines(value string) string { |
| 243 | value = strings.ReplaceAll(value, "\r\n", "\n") |
| 244 | return strings.ReplaceAll(value, "\r", "\n") |
| 245 | } |
| 246 | |
| 247 | func parseFramedSections(body string) (Sections, bool) { |
| 248 | prefix := preamble + "\n\nSection lengths: " |
| 249 | if !strings.HasPrefix(body, prefix) { |
| 250 | return Sections{}, false |
| 251 | } |
| 252 | rest := strings.TrimPrefix(body, prefix) |
| 253 | manifestLine, payload, ok := strings.Cut(rest, "\n\n") |
| 254 | if !ok { |
| 255 | return Sections{}, false |
| 256 | } |
| 257 | lengths, ok := parseSectionManifest(manifestLine) |
| 258 | if !ok { |
| 259 | return Sections{}, false |
| 260 | } |
| 261 | specs := []struct { |
| 262 | name string |
| 263 | set func(*Sections, string) |
| 264 | }{ |
| 265 | {"Environment", func(s *Sections, v string) { s.Environment = v }}, |
| 266 | {"Workspace", func(s *Sections, v string) { s.Workspace = v }}, |
| 267 | {"Background memory", func(s *Sections, v string) { s.BackgroundMemory = v }}, |
| 268 | {"Skills catalog", func(s *Sections, v string) { s.SkillsCatalog = v }}, |
| 269 | } |
| 270 | var sections Sections |
| 271 | for i, spec := range specs { |
| 272 | n := lengths[i] |
| 273 | if n == 0 { |
| 274 | continue |
| 275 | } |
| 276 | heading := "## " + spec.name + "\n\n" |
| 277 | if !strings.HasPrefix(payload, heading) { |
| 278 | return Sections{}, false |
| 279 | } |
| 280 | payload = strings.TrimPrefix(payload, heading) |
| 281 | if len(payload) < n || !utf8.ValidString(payload[:n]) { |
| 282 | return Sections{}, false |
| 283 | } |
| 284 | value := payload[:n] |
| 285 | if value == "" || strings.TrimSpace(value) != value { |
| 286 | return Sections{}, false |
| 287 | } |
| 288 | spec.set(§ions, value) |
| 289 | payload = payload[n:] |
| 290 | for j := i + 1; j < len(specs); j++ { |
| 291 | if lengths[j] > 0 { |
| 292 | if !strings.HasPrefix(payload, "\n\n") { |
| 293 | return Sections{}, false |
| 294 | } |
| 295 | payload = strings.TrimPrefix(payload, "\n\n") |
| 296 | break |
| 297 | } |
| 298 | } |
| 299 | } |
| 300 | return sections, payload == "" |
| 301 | } |
| 302 | |
| 303 | func parseSectionManifest(line string) ([4]int, bool) { |
| 304 | want := [...]string{"Environment", "Workspace", "Background memory", "Skills catalog"} |
| 305 | var lengths [4]int |
| 306 | parts := strings.Split(line, "; ") |
| 307 | if len(parts) != len(want) { |
| 308 | return lengths, false |
| 309 | } |
| 310 | for i, part := range parts { |
| 311 | name, raw, ok := strings.Cut(part, "=") |
| 312 | if !ok || name != want[i] { |
| 313 | return lengths, false |
| 314 | } |
| 315 | n, err := strconv.Atoi(raw) |
| 316 | if err != nil || n < 0 { |
| 317 | return lengths, false |
| 318 | } |
| 319 | lengths[i] = n |
| 320 | } |
| 321 | return lengths, true |
| 322 | } |
| 323 | |
| 324 | func parseSections(body string) (Sections, bool) { |
| 325 | if body == preamble { |
| 326 | return Sections{}, true |
| 327 | } |
| 328 | if !strings.HasPrefix(body, preamble+"\n\n") { |
| 329 | return Sections{}, false |
| 330 | } |
| 331 | rest := strings.TrimPrefix(body, preamble+"\n\n") |
| 332 | type sectionSpec struct { |
| 333 | heading string |
| 334 | set func(*Sections, string) |
| 335 | } |
| 336 | specs := []sectionSpec{ |
| 337 | {"## Environment\n\n", func(s *Sections, v string) { s.Environment = v }}, |
| 338 | {"## Workspace\n\n", func(s *Sections, v string) { s.Workspace = v }}, |
| 339 | {"## Background memory\n\n", func(s *Sections, v string) { s.BackgroundMemory = v }}, |
| 340 | {"## Skills catalog\n\n", func(s *Sections, v string) { s.SkillsCatalog = v }}, |
| 341 | } |
| 342 | var sections Sections |
| 343 | nextSpec := 0 |
| 344 | for rest != "" { |
| 345 | found := -1 |
| 346 | for i := nextSpec; i < len(specs); i++ { |
| 347 | if strings.HasPrefix(rest, specs[i].heading) { |
| 348 | found = i |
| 349 | break |
| 350 | } |
| 351 | } |
| 352 | if found < 0 { |
| 353 | return Sections{}, false |
| 354 | } |
| 355 | rest = strings.TrimPrefix(rest, specs[found].heading) |
| 356 | end := len(rest) |
| 357 | for i := found + 1; i < len(specs); i++ { |
| 358 | if at := strings.Index(rest, "\n\n"+specs[i].heading); at >= 0 && at < end { |
| 359 | end = at |
| 360 | } |
| 361 | } |
| 362 | value := rest[:end] |
| 363 | if value == "" || strings.TrimSpace(value) != value { |
| 364 | return Sections{}, false |
| 365 | } |
| 366 | specs[found].set(§ions, value) |
| 367 | nextSpec = found + 1 |
| 368 | if end == len(rest) { |
| 369 | rest = "" |
| 370 | } else { |
| 371 | rest = strings.TrimPrefix(rest[end:], "\n\n") |
| 372 | } |
| 373 | } |
| 374 | return sections, true |
| 375 | } |
| 376 | |
| 377 | func sectionStat(value string) SectionStat { |
| 378 | if value == "" { |
| 379 | return SectionStat{} |
| 380 | } |
| 381 | return SectionStat{Digest: digestOf(value), Chars: utf8.RuneCountInString(value)} |
| 382 | } |
| 383 | |
| 384 | func digestOf(body string) string { |
| 385 | return fmt.Sprintf("%x", sha256.Sum256([]byte(body))) |
| 386 | } |
| 387 |