| 1 | package memory |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "regexp" |
| 8 | "sort" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "reasonix/internal/config" |
| 14 | "reasonix/internal/fileutil" |
| 15 | fileencoding "reasonix/internal/fileutil/encoding" |
| 16 | "reasonix/internal/frontmatter" |
| 17 | ) |
| 18 | |
| 19 | // Store is the scoped auto-memory store: project and global directories of |
| 20 | // one-fact-per-file Markdown notes, each with a MEMORY.md index. |
| 21 | // The model maintains it through the `remember` tool; the index loads into the |
| 22 | // cached system-prompt prefix at boot so the model always knows what it has |
| 23 | // saved, and reads individual facts on demand with the `memory` tool. The whole |
| 24 | // thing is plain files the user can edit by hand. |
| 25 | // |
| 26 | // Scope and type are independent: callers choose whether a fact belongs to the |
| 27 | // current project or every project, while Type only classifies its contents. |
| 28 | // List() and Index() merge both directories so every session sees the full set. |
| 29 | type Store struct { |
| 30 | Dir string // ...reasonix/projects/<slug>/memory |
| 31 | GlobalDir string // ...reasonix/memory/global (shared across projects) |
| 32 | } |
| 33 | |
| 34 | // Type classifies a memory, mirroring the auto-memory taxonomy. |
| 35 | type Type string |
| 36 | |
| 37 | const ( |
| 38 | TypeUser Type = "user" // who the user is: role, preferences, expertise |
| 39 | TypeFeedback Type = "feedback" // guidance on how to work (with why + how-to-apply) |
| 40 | TypeProject Type = "project" // ongoing work / goals / constraints not in the code |
| 41 | TypeReference Type = "reference" // pointers to external resources (URLs, tickets) |
| 42 | ) |
| 43 | |
| 44 | // validTypes is the closed set the `remember` tool accepts; anything else |
| 45 | // normalises to TypeProject. |
| 46 | var validTypes = map[Type]bool{TypeUser: true, TypeFeedback: true, TypeProject: true, TypeReference: true} |
| 47 | |
| 48 | // NormalizeType coerces an arbitrary string to a known Type, defaulting to |
| 49 | // TypeProject so a sloppy tool argument never blocks a save. |
| 50 | func NormalizeType(s string) Type { |
| 51 | t := Type(strings.ToLower(strings.TrimSpace(s))) |
| 52 | if validTypes[t] { |
| 53 | return t |
| 54 | } |
| 55 | return TypeProject |
| 56 | } |
| 57 | |
| 58 | // FactScope controls where an auto-memory fact is active. It is intentionally |
| 59 | // separate from Type: project feedback should not silently become global merely |
| 60 | // because it is classified as feedback. |
| 61 | type FactScope string |
| 62 | |
| 63 | const ( |
| 64 | FactScopeProject FactScope = "project" |
| 65 | FactScopeGlobal FactScope = "global" |
| 66 | ) |
| 67 | |
| 68 | // NormalizeFactScope defaults to the current project. Global memory must be an |
| 69 | // explicit choice because it affects every workspace. |
| 70 | func NormalizeFactScope(s string) FactScope { |
| 71 | if FactScope(strings.ToLower(strings.TrimSpace(s))) == FactScopeGlobal { |
| 72 | return FactScopeGlobal |
| 73 | } |
| 74 | return FactScopeProject |
| 75 | } |
| 76 | |
| 77 | // Memory is one stored fact. |
| 78 | type Memory struct { |
| 79 | ID string // immutable identity; Name may change without changing ID |
| 80 | Revision int // monotonic content revision, starting at 1 |
| 81 | CreatedAt time.Time |
| 82 | UpdatedAt time.Time |
| 83 | Name string // kebab-case slug; also the file stem (<name>.md) |
| 84 | Title string // human-readable index label; falls back to a de-kebabed Name |
| 85 | Description string // one-line summary used for the index and recall |
| 86 | Type Type |
| 87 | Scope FactScope // project by default; global only when explicitly requested |
| 88 | Activation Activation // persisted choice; "" = unset, resolved by ResolveActivation |
| 89 | Volatility Volatility // how fast the fact ages; "" = unset, type default applies |
| 90 | SubjectKey string // which question the fact answers (project.package_manager); one active value per scope+subject |
| 91 | ExpiresAt time.Time // hard freshness boundary; zero = never expires |
| 92 | LastVerifiedAt time.Time // last explicit confirmation; renews the freshness clock |
| 93 | Keywords string // search aliases (bilingual synonyms, related commands); recall-only, never rendered into the index |
| 94 | Body string // the fact itself (Markdown) |
| 95 | } |
| 96 | |
| 97 | // ArchivedMemory is a saved fact that has been removed from active memory but |
| 98 | // kept on disk for traceability. |
| 99 | type ArchivedMemory struct { |
| 100 | Memory |
| 101 | Path string |
| 102 | ArchivedAt time.Time |
| 103 | } |
| 104 | |
| 105 | // StoreFor resolves the auto-memory directory for a project working dir under |
| 106 | // Reasonix home, e.g. ~/.reasonix/projects/-Users-me-proj/memory. |
| 107 | // A "" userDir (config dir unresolvable) yields a zero Store, which all methods |
| 108 | // treat as a disabled no-op. |
| 109 | func StoreFor(userDir, cwd string) Store { |
| 110 | if userDir == "" { |
| 111 | return Store{} |
| 112 | } |
| 113 | return Store{ |
| 114 | Dir: filepath.Join(userDir, "projects", config.WorkspaceSlug(absOf(cwd)), "memory"), |
| 115 | GlobalDir: filepath.Join(userDir, "memory", "global"), |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // DirFor returns the directory for an explicit fact scope. When GlobalDir is |
| 120 | // unavailable, global writes fall back to Dir rather than being dropped. |
| 121 | func (s Store) DirFor(scope FactScope) string { |
| 122 | if s.GlobalDir != "" && NormalizeFactScope(string(scope)) == FactScopeGlobal { |
| 123 | return s.GlobalDir |
| 124 | } |
| 125 | return s.Dir |
| 126 | } |
| 127 | |
| 128 | // indexFile is the human-readable index of saved memories. |
| 129 | const indexFile = "MEMORY.md" |
| 130 | |
| 131 | // dirs returns the directories to read from, in order: GlobalDir first (shared |
| 132 | // memories), then Dir (project-specific). |
| 133 | func (s Store) dirs() []string { |
| 134 | if s.GlobalDir != "" && s.GlobalDir != s.Dir { |
| 135 | return []string{s.GlobalDir, s.Dir} |
| 136 | } |
| 137 | return []string{s.Dir} |
| 138 | } |
| 139 | |
| 140 | // Path returns the absolute file path a memory with the given name lives at. |
| 141 | // It checks GlobalDir first, then Dir, returning the first match. If no file |
| 142 | // exists yet, it returns the path in Dir (the default project scope). |
| 143 | func (s Store) Path(name string) string { |
| 144 | if _, path, ok := s.findActive(name); ok { |
| 145 | return path |
| 146 | } |
| 147 | ref := parseMemoryReference(name) |
| 148 | stem := ref.name + ".md" |
| 149 | if ref.qualified { |
| 150 | p, err := safeJoin(s.DirFor(ref.scope), stem) |
| 151 | if err != nil { |
| 152 | return "" |
| 153 | } |
| 154 | return p |
| 155 | } |
| 156 | for _, dir := range s.dirs() { |
| 157 | if dir == "" { |
| 158 | continue |
| 159 | } |
| 160 | p, err := safeJoin(dir, stem) |
| 161 | if err != nil { |
| 162 | continue |
| 163 | } |
| 164 | if _, err := os.Stat(p); err == nil { |
| 165 | return p |
| 166 | } |
| 167 | } |
| 168 | p, sjErr := safeJoin(s.Dir, stem) |
| 169 | if sjErr != nil { |
| 170 | return "" |
| 171 | } |
| 172 | return p |
| 173 | } |
| 174 | |
| 175 | // Save writes (or overwrites) a memory file and refreshes its MEMORY.md index |
| 176 | // line. It is the single mutation entry point — the `remember` tool, the desktop |
| 177 | // editor, and any future importer all go through here so the index never drifts |
| 178 | // from the files. Returns the path written. |
| 179 | func (s Store) Save(m Memory) (string, error) { |
| 180 | result, err := s.SaveWithOptions(m, SaveOptions{}) |
| 181 | return result.Path, err |
| 182 | } |
| 183 | |
| 184 | // Archive removes a memory from the active store and moves its file under |
| 185 | // .archive/ for traceability. A missing file is not an error; the goal state |
| 186 | // (not active) already holds. It returns the archive path, or "" when no file |
| 187 | // existed to archive. |
| 188 | // When both GlobalDir and Dir exist, it archives from every directory the |
| 189 | // memory appears in (handles migration duplicates). |
| 190 | func (s Store) Archive(name string) (string, error) { |
| 191 | memoryStoreMutationMu.Lock() |
| 192 | defer memoryStoreMutationMu.Unlock() |
| 193 | return s.archiveLocked(name) |
| 194 | } |
| 195 | |
| 196 | func (s Store) archiveLocked(name string) (string, error) { |
| 197 | if s.Dir == "" && s.GlobalDir == "" { |
| 198 | return "", fmt.Errorf("memory store unavailable (no user config dir)") |
| 199 | } |
| 200 | ref := strings.TrimSpace(name) |
| 201 | parsed := parseMemoryReference(ref) |
| 202 | if active, path, ok := s.findActive(ref); ok && ref == active.ID { |
| 203 | return archiveMemoryInDir(filepath.Dir(path), active.Name) |
| 204 | } else if ok && parsed.qualified { |
| 205 | return archiveMemoryInDir(filepath.Dir(path), active.Name) |
| 206 | } else if ok { |
| 207 | name = active.Name |
| 208 | } else if parsed.qualified { |
| 209 | if parsed.name == "" { |
| 210 | return "", fmt.Errorf("memory needs a name") |
| 211 | } |
| 212 | return archiveMemoryInDir(s.DirFor(parsed.scope), parsed.name) |
| 213 | } else { |
| 214 | name = slug(name) |
| 215 | } |
| 216 | if name == "" { |
| 217 | return "", fmt.Errorf("memory needs a name") |
| 218 | } |
| 219 | var lastPath string |
| 220 | for _, dir := range s.dirs() { |
| 221 | if dir == "" { |
| 222 | continue |
| 223 | } |
| 224 | p, err := archiveInDir(dir, name) |
| 225 | if err != nil { |
| 226 | return "", err |
| 227 | } |
| 228 | if p != "" || indexContainsIn(dir, name) { |
| 229 | if err := flushIndexIn(dir, indexLinesExceptIn(dir, name)); err != nil { |
| 230 | return "", err |
| 231 | } |
| 232 | } |
| 233 | if p != "" { |
| 234 | lastPath = p |
| 235 | } |
| 236 | } |
| 237 | return lastPath, nil |
| 238 | } |
| 239 | |
| 240 | func archiveMemoryInDir(dir, name string) (string, error) { |
| 241 | path, err := archiveInDir(dir, name) |
| 242 | if err != nil { |
| 243 | return "", err |
| 244 | } |
| 245 | if path != "" || indexContainsIn(dir, name) { |
| 246 | if err := flushIndexIn(dir, indexLinesExceptIn(dir, name)); err != nil { |
| 247 | return "", err |
| 248 | } |
| 249 | } |
| 250 | return path, nil |
| 251 | } |
| 252 | |
| 253 | // Delete removes a memory from the active store and its MEMORY.md line — the |
| 254 | // model's `forget` path and the user's way to prune a stale fact. It archives |
| 255 | // the file instead of permanently deleting it so wrong memories remain |
| 256 | // traceable. A missing file is not an error; the goal state (gone) holds either |
| 257 | // way. |
| 258 | func (s Store) Delete(name string) error { |
| 259 | _, err := s.Archive(name) |
| 260 | return err |
| 261 | } |
| 262 | |
| 263 | func archiveInDir(dir, name string) (string, error) { |
| 264 | root, err := os.OpenRoot(dir) |
| 265 | if os.IsNotExist(err) { |
| 266 | return "", nil |
| 267 | } |
| 268 | if err != nil { |
| 269 | return "", err |
| 270 | } |
| 271 | defer root.Close() |
| 272 | |
| 273 | file := name + ".md" |
| 274 | if _, err := root.Stat(file); err != nil { |
| 275 | if os.IsNotExist(err) { |
| 276 | return "", nil |
| 277 | } |
| 278 | return "", err |
| 279 | } |
| 280 | if err := root.MkdirAll(".archive", 0o755); err != nil { |
| 281 | return "", err |
| 282 | } |
| 283 | dest, err := archivePath(root, name, time.Now().UTC()) |
| 284 | if err != nil { |
| 285 | return "", err |
| 286 | } |
| 287 | if err := renameMemoryFile(root, file, dest); err != nil { |
| 288 | return "", err |
| 289 | } |
| 290 | out, err := safeJoin(dir, dest) |
| 291 | if err != nil { |
| 292 | return "", err |
| 293 | } |
| 294 | return out, nil |
| 295 | } |
| 296 | |
| 297 | func archivePath(root *os.Root, name string, when time.Time) (string, error) { |
| 298 | stem := when.Format("20060102-150405.000") + "-" + name |
| 299 | path := filepath.Join(".archive", stem+".md") |
| 300 | if _, err := root.Stat(path); os.IsNotExist(err) { |
| 301 | return path, nil |
| 302 | } else if err != nil { |
| 303 | return "", err |
| 304 | } |
| 305 | for i := 1; ; i++ { |
| 306 | path = filepath.Join(".archive", fmt.Sprintf("%s-%d.md", stem, i)) |
| 307 | if _, err := root.Stat(path); os.IsNotExist(err) { |
| 308 | return path, nil |
| 309 | } else if err != nil { |
| 310 | return "", err |
| 311 | } |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | func safeJoin(base, name string) (string, error) { |
| 316 | if base == "" { |
| 317 | return "", fmt.Errorf("memory store unavailable (no user config dir)") |
| 318 | } |
| 319 | if !filepath.IsLocal(name) { |
| 320 | return "", fmt.Errorf("memory path escapes store: %s", name) |
| 321 | } |
| 322 | baseAbs, err := filepath.Abs(base) |
| 323 | if err != nil { |
| 324 | return "", err |
| 325 | } |
| 326 | path := filepath.Join(baseAbs, name) |
| 327 | pathAbs, err := filepath.Abs(path) |
| 328 | if err != nil { |
| 329 | return "", err |
| 330 | } |
| 331 | rel, err := filepath.Rel(baseAbs, pathAbs) |
| 332 | if err != nil { |
| 333 | return "", err |
| 334 | } |
| 335 | if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { |
| 336 | return "", fmt.Errorf("memory path escapes store: %s", name) |
| 337 | } |
| 338 | return pathAbs, nil |
| 339 | } |
| 340 | |
| 341 | func renameMemoryFile(root *os.Root, path, dest string) error { |
| 342 | err := root.Rename(path, dest) |
| 343 | if err == nil || os.IsNotExist(err) { |
| 344 | return nil |
| 345 | } |
| 346 | if !os.IsPermission(err) { |
| 347 | return err |
| 348 | } |
| 349 | repairOwnerWrite(root, path, false) |
| 350 | repairOwnerWrite(root, filepath.Dir(path), true) |
| 351 | repairOwnerWrite(root, filepath.Dir(dest), true) |
| 352 | err = root.Rename(path, dest) |
| 353 | if err == nil || os.IsNotExist(err) { |
| 354 | return nil |
| 355 | } |
| 356 | return err |
| 357 | } |
| 358 | |
| 359 | func repairOwnerWrite(root *os.Root, path string, dir bool) { |
| 360 | info, err := root.Stat(path) |
| 361 | if err != nil { |
| 362 | return |
| 363 | } |
| 364 | need := os.FileMode(0o600) |
| 365 | if dir { |
| 366 | need = 0o700 |
| 367 | } |
| 368 | _ = root.Chmod(path, info.Mode().Perm()|need) |
| 369 | } |
| 370 | |
| 371 | // indexLineRe matches a managed index line so reindex/Delete can target the line |
| 372 | // for one memory by its filename without disturbing the rest of a hand-edited |
| 373 | // MEMORY.md. |
| 374 | var indexLineRe = regexp.MustCompile(`(?m)^\s*-\s\[.+?\]\(([^)]+)\.md\)\s*—\s.*$`) |
| 375 | |
| 376 | // indexLinesExceptIn returns the managed MEMORY.md lines keyed by filename stem |
| 377 | // in the given directory, dropping the entry for name (a missing index → empty map). |
| 378 | func indexLinesExceptIn(dir, name string) map[string]string { |
| 379 | existing, _ := fileencoding.ReadFileUTF8(filepath.Join(dir, indexFile)) |
| 380 | keep := map[string]string{} |
| 381 | for line := range strings.SplitSeq(string(existing), "\n") { |
| 382 | if mt := indexLineRe.FindStringSubmatch(line); mt != nil && mt[1] != name { |
| 383 | keep[mt[1]] = strings.TrimRight(line, "\r") |
| 384 | } |
| 385 | } |
| 386 | return keep |
| 387 | } |
| 388 | |
| 389 | func indexContainsIn(dir, name string) bool { |
| 390 | existing, err := fileencoding.ReadFileUTF8(filepath.Join(dir, indexFile)) |
| 391 | if err != nil { |
| 392 | return false |
| 393 | } |
| 394 | for line := range strings.SplitSeq(string(existing), "\n") { |
| 395 | if mt := indexLineRe.FindStringSubmatch(line); mt != nil && mt[1] == name { |
| 396 | return true |
| 397 | } |
| 398 | } |
| 399 | return false |
| 400 | } |
| 401 | |
| 402 | // flushIndexIn rewrites MEMORY.md in the given directory from the managed lines, |
| 403 | // preserving hand-written content. Managed lines are updated or removed, and |
| 404 | // new managed entries are appended in sorted order. |
| 405 | func flushIndexIn(dir string, lines map[string]string) error { |
| 406 | path := filepath.Join(dir, indexFile) |
| 407 | existing, _ := fileencoding.ReadFileUTF8(path) |
| 408 | processed := map[string]bool{} |
| 409 | var preserved strings.Builder |
| 410 | preservedEmpty := true |
| 411 | for line := range strings.SplitSeq(string(existing), "\n") { |
| 412 | trimmed := strings.TrimRight(line, "\r") |
| 413 | if mt := indexLineRe.FindStringSubmatch(trimmed); mt != nil { |
| 414 | name := mt[1] |
| 415 | if fresh, ok := lines[name]; ok { |
| 416 | preserved.WriteString(fresh) |
| 417 | preserved.WriteString("\n") |
| 418 | processed[name] = true |
| 419 | preservedEmpty = false |
| 420 | } |
| 421 | continue |
| 422 | } |
| 423 | preserved.WriteString(trimmed) |
| 424 | preserved.WriteString("\n") |
| 425 | if strings.TrimSpace(trimmed) != "" { |
| 426 | preservedEmpty = false |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | names := make([]string, 0, len(lines)) |
| 431 | for n := range lines { |
| 432 | if !processed[n] { |
| 433 | names = append(names, n) |
| 434 | } |
| 435 | } |
| 436 | sort.Strings(names) |
| 437 | |
| 438 | var b strings.Builder |
| 439 | if preservedEmpty && len(names) > 0 { |
| 440 | b.WriteString("# Memory\n\n") |
| 441 | } else { |
| 442 | b.WriteString(preserved.String()) |
| 443 | } |
| 444 | for _, n := range names { |
| 445 | b.WriteString(lines[n]) |
| 446 | b.WriteString("\n") |
| 447 | } |
| 448 | result := strings.TrimRight(b.String(), "\n") |
| 449 | if result != "" { |
| 450 | result += "\n" |
| 451 | } |
| 452 | // The index is derived state, but a torn write would still hide facts |
| 453 | // from the next real turn's session-context until the next reindex. |
| 454 | return fileutil.AtomicWriteFile(path, []byte(result), 0o644) |
| 455 | } |
| 456 | |
| 457 | // reindexIn rewrites the MEMORY.md line for name in the given directory, |
| 458 | // preserving every other managed line. |
| 459 | func reindexIn(dir, name string, m Memory) error { |
| 460 | lines := indexLinesExceptIn(dir, name) |
| 461 | lines[name] = renderIndexLine(name, m) |
| 462 | return flushIndexIn(dir, lines) |
| 463 | } |
| 464 | |
| 465 | func renderIndexLine(name string, m Memory) string { |
| 466 | marker := "" |
| 467 | if ResolveActivation(m) == ActivationPinned { |
| 468 | marker = " pinned" // the body already rides session-context; no need to read it |
| 469 | } |
| 470 | return fmt.Sprintf("- [%s](%s.md) — [%s/%s%s] %s", |
| 471 | displayTitle(m.Title, name), name, |
| 472 | NormalizeFactScope(string(m.Scope)), NormalizeType(string(m.Type)), marker, oneLine(m.Description)) |
| 473 | } |
| 474 | |
| 475 | // List returns the saved memories parsed from their files, sorted by name. Used |
| 476 | // by `/memory` and the desktop memory panel. Reads from both GlobalDir and Dir, |
| 477 | // merging results. Files that fail to parse are skipped so one bad file never |
| 478 | // hides the rest. |
| 479 | func (s Store) List() []Memory { |
| 480 | if s.Dir == "" && s.GlobalDir == "" { |
| 481 | return nil |
| 482 | } |
| 483 | var out []Memory |
| 484 | seen := map[string]bool{} |
| 485 | for _, dir := range s.dirs() { |
| 486 | if dir == "" { |
| 487 | continue |
| 488 | } |
| 489 | entries, err := os.ReadDir(dir) |
| 490 | if err != nil { |
| 491 | continue |
| 492 | } |
| 493 | for _, e := range entries { |
| 494 | if e.IsDir() || e.Name() == indexFile || !strings.HasSuffix(e.Name(), ".md") { |
| 495 | continue |
| 496 | } |
| 497 | if m, ok := loadMemory(filepath.Join(dir, e.Name())); ok { |
| 498 | if m.Scope == "" { |
| 499 | m.Scope = s.scopeForDir(dir) |
| 500 | } |
| 501 | if !seen[m.Name] { |
| 502 | out = append(out, m) |
| 503 | seen[m.Name] = true |
| 504 | } |
| 505 | } |
| 506 | } |
| 507 | } |
| 508 | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) |
| 509 | return out |
| 510 | } |
| 511 | |
| 512 | // ListAll returns every active fact from both scopes without the legacy |
| 513 | // name-based deduplication performed by List. Callers that understand scope can |
| 514 | // use it to resolve project-over-global overrides without hiding either source. |
| 515 | func (s Store) ListAll() []Memory { |
| 516 | if s.Dir == "" && s.GlobalDir == "" { |
| 517 | return nil |
| 518 | } |
| 519 | var out []Memory |
| 520 | for _, dir := range s.dirs() { |
| 521 | if dir == "" { |
| 522 | continue |
| 523 | } |
| 524 | entries, err := os.ReadDir(dir) |
| 525 | if err != nil { |
| 526 | continue |
| 527 | } |
| 528 | for _, entry := range entries { |
| 529 | if entry.IsDir() || entry.Name() == indexFile || !strings.HasSuffix(entry.Name(), ".md") { |
| 530 | continue |
| 531 | } |
| 532 | memory, ok := loadMemory(filepath.Join(dir, entry.Name())) |
| 533 | if !ok { |
| 534 | continue |
| 535 | } |
| 536 | if memory.Scope == "" { |
| 537 | memory.Scope = s.scopeForDir(dir) |
| 538 | } |
| 539 | out = append(out, memory) |
| 540 | } |
| 541 | } |
| 542 | sort.Slice(out, func(i, j int) bool { |
| 543 | if out[i].Name != out[j].Name { |
| 544 | return out[i].Name < out[j].Name |
| 545 | } |
| 546 | if out[i].Scope != out[j].Scope { |
| 547 | return out[i].Scope < out[j].Scope |
| 548 | } |
| 549 | return out[i].ID < out[j].ID |
| 550 | }) |
| 551 | return out |
| 552 | } |
| 553 | |
| 554 | // PinnedGuidanceBudgetChars caps the total pinned-body runes session-context |
| 555 | // carries. Guidance that must always hold belongs in REASONIX.md/AGENTS.md |
| 556 | // instructions; pinned memory is the bounded middle tier between instructions |
| 557 | // and retrieval-only facts, and the cap is enforced at write time so the |
| 558 | // snapshot always equals exactly what the user curated. |
| 559 | const PinnedGuidanceBudgetChars = 1500 |
| 560 | |
| 561 | // pinnedGuidance snapshots explicitly pinned facts (plus legacy global |
| 562 | // user/feedback, which ResolveActivation keeps pinned for compatibility) for |
| 563 | // session-context, most recently updated first. |
| 564 | func (s Store) pinnedGuidance() []Memory { |
| 565 | var out []Memory |
| 566 | for _, dir := range s.dirs() { |
| 567 | if dir == "" { |
| 568 | continue |
| 569 | } |
| 570 | entries, err := os.ReadDir(dir) |
| 571 | if err != nil { |
| 572 | continue |
| 573 | } |
| 574 | for _, e := range entries { |
| 575 | if e.IsDir() || e.Name() == indexFile || !strings.HasSuffix(e.Name(), ".md") { |
| 576 | continue |
| 577 | } |
| 578 | m, ok := loadMemory(filepath.Join(dir, e.Name())) |
| 579 | if !ok { |
| 580 | continue |
| 581 | } |
| 582 | if m.Scope == "" { |
| 583 | m.Scope = s.scopeForDir(dir) |
| 584 | } |
| 585 | if ResolveActivation(m) != ActivationPinned || strings.TrimSpace(m.Body) == "" { |
| 586 | continue |
| 587 | } |
| 588 | out = append(out, m) |
| 589 | } |
| 590 | } |
| 591 | sort.Slice(out, func(i, j int) bool { |
| 592 | if !out[i].UpdatedAt.Equal(out[j].UpdatedAt) { |
| 593 | return out[i].UpdatedAt.After(out[j].UpdatedAt) |
| 594 | } |
| 595 | return out[i].Name < out[j].Name |
| 596 | }) |
| 597 | return out |
| 598 | } |
| 599 | |
| 600 | // pinnedGuidanceForProject removes pinned guidance shadowed by an equivalent |
| 601 | // project fact before the stable session prefix is built. This makes the |
| 602 | // documented project-over-global rule deterministic on the first turn instead |
| 603 | // of depending on whether automatic recall happens to match the request. |
| 604 | func (s Store) pinnedGuidanceForProject() []Memory { |
| 605 | guidance := s.pinnedGuidance() |
| 606 | if len(guidance) == 0 || s.Dir == "" { |
| 607 | return guidance |
| 608 | } |
| 609 | projectKeys := map[string]bool{} |
| 610 | for _, fact := range s.ListAll() { |
| 611 | if NormalizeFactScope(string(fact.Scope)) != FactScopeProject { |
| 612 | continue |
| 613 | } |
| 614 | for _, key := range recallIdentityKeys(fact) { |
| 615 | if strings.HasSuffix(key, ":") { |
| 616 | continue |
| 617 | } |
| 618 | projectKeys[key] = true |
| 619 | } |
| 620 | } |
| 621 | if len(projectKeys) == 0 { |
| 622 | return guidance |
| 623 | } |
| 624 | out := guidance[:0] |
| 625 | for _, fact := range guidance { |
| 626 | // Project pinned facts always stay: the shadow rule only suppresses a |
| 627 | // GLOBAL fact that an equivalent project fact overrides. |
| 628 | shadowed := false |
| 629 | if NormalizeFactScope(string(fact.Scope)) == FactScopeGlobal { |
| 630 | for _, key := range recallIdentityKeys(fact) { |
| 631 | if projectKeys[key] { |
| 632 | shadowed = true |
| 633 | break |
| 634 | } |
| 635 | } |
| 636 | } |
| 637 | if !shadowed { |
| 638 | out = append(out, fact) |
| 639 | } |
| 640 | } |
| 641 | return out |
| 642 | } |
| 643 | |
| 644 | // ListArchived returns archived memories parsed from .archive/, newest first. |
| 645 | // Archived files stay out of List() and the prompt index, so stale facts remain |
| 646 | // inspectable without being reused as active truth. Reads from both GlobalDir |
| 647 | // and Dir. |
| 648 | func (s Store) ListArchived() []ArchivedMemory { |
| 649 | if s.Dir == "" && s.GlobalDir == "" { |
| 650 | return nil |
| 651 | } |
| 652 | var out []ArchivedMemory |
| 653 | for _, base := range s.dirs() { |
| 654 | if base == "" { |
| 655 | continue |
| 656 | } |
| 657 | dir := filepath.Join(base, ".archive") |
| 658 | entries, err := os.ReadDir(dir) |
| 659 | if err != nil { |
| 660 | continue |
| 661 | } |
| 662 | for _, e := range entries { |
| 663 | if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { |
| 664 | continue |
| 665 | } |
| 666 | path := filepath.Join(dir, e.Name()) |
| 667 | m, ok := loadMemory(path) |
| 668 | if !ok { |
| 669 | continue |
| 670 | } |
| 671 | if m.Scope == "" { |
| 672 | m.Scope = s.scopeForDir(base) |
| 673 | } |
| 674 | when := archiveTimeFromName(e.Name()) |
| 675 | if when.IsZero() { |
| 676 | if info, err := e.Info(); err == nil { |
| 677 | when = info.ModTime() |
| 678 | } |
| 679 | } |
| 680 | out = append(out, ArchivedMemory{Memory: m, Path: path, ArchivedAt: when}) |
| 681 | } |
| 682 | } |
| 683 | sort.Slice(out, func(i, j int) bool { |
| 684 | if !out[i].ArchivedAt.Equal(out[j].ArchivedAt) { |
| 685 | return out[i].ArchivedAt.After(out[j].ArchivedAt) |
| 686 | } |
| 687 | if out[i].Name != out[j].Name { |
| 688 | return out[i].Name < out[j].Name |
| 689 | } |
| 690 | return out[i].Path < out[j].Path |
| 691 | }) |
| 692 | return out |
| 693 | } |
| 694 | |
| 695 | func archiveTimeFromName(name string) time.Time { |
| 696 | const stampLen = len("20060102-150405.000") |
| 697 | if len(name) <= stampLen || name[stampLen] != '-' { |
| 698 | return time.Time{} |
| 699 | } |
| 700 | when, err := time.ParseInLocation("20060102-150405.000", name[:stampLen], time.UTC) |
| 701 | if err != nil { |
| 702 | return time.Time{} |
| 703 | } |
| 704 | return when |
| 705 | } |
| 706 | |
| 707 | // loadMemory parses one fact file back into a Memory. It tolerates the minimal |
| 708 | // frontmatter render writes; a file without frontmatter still loads with its |
| 709 | // body and a name derived from the filename. |
| 710 | func loadMemory(path string) (Memory, bool) { |
| 711 | b, err := fileencoding.ReadFileUTF8(path) |
| 712 | if err != nil { |
| 713 | return Memory{}, false |
| 714 | } |
| 715 | fm, body := splitFrontmatter(string(b)) |
| 716 | m := Memory{ |
| 717 | ID: fm["id"], |
| 718 | Revision: parsePositiveInt(fm["revision"]), |
| 719 | CreatedAt: parseMemoryTime(fm["created_at"]), |
| 720 | UpdatedAt: parseMemoryTime(fm["updated_at"]), |
| 721 | Name: fm["name"], |
| 722 | Title: fm["title"], |
| 723 | Description: fm["description"], |
| 724 | Keywords: fm["keywords"], |
| 725 | Activation: NormalizeActivation(fm["activation"]), |
| 726 | Volatility: NormalizeVolatility(fm["volatility"]), |
| 727 | SubjectKey: NormalizeSubjectKey(fm["subject_key"]), |
| 728 | ExpiresAt: parseMemoryTime(fm["expires_at"]), |
| 729 | LastVerifiedAt: parseMemoryTime(fm["last_verified_at"]), |
| 730 | Type: persistedFactType(fm), |
| 731 | Scope: factScopeFromFrontmatter(fm["scope"]), |
| 732 | Body: strings.TrimSpace(body), |
| 733 | } |
| 734 | if m.Name == "" { |
| 735 | m.Name = strings.TrimSuffix(filepath.Base(path), ".md") |
| 736 | } |
| 737 | if m.ID == "" { |
| 738 | m.ID = legacyMemoryID(m.Name, legacyIdentityScope(m)) |
| 739 | } |
| 740 | if m.Revision <= 0 { |
| 741 | m.Revision = 1 |
| 742 | } |
| 743 | if info, err := os.Stat(path); err == nil { |
| 744 | if m.CreatedAt.IsZero() { |
| 745 | m.CreatedAt = info.ModTime().UTC() |
| 746 | } |
| 747 | if m.UpdatedAt.IsZero() { |
| 748 | m.UpdatedAt = info.ModTime().UTC() |
| 749 | } |
| 750 | } |
| 751 | return m, true |
| 752 | } |
| 753 | |
| 754 | func legacyIdentityScope(m Memory) FactScope { |
| 755 | if m.Scope != "" { |
| 756 | return NormalizeFactScope(string(m.Scope)) |
| 757 | } |
| 758 | if m.Type == TypeUser || m.Type == TypeFeedback { |
| 759 | return FactScopeGlobal |
| 760 | } |
| 761 | return FactScopeProject |
| 762 | } |
| 763 | |
| 764 | func parsePositiveInt(value string) int { |
| 765 | n, err := strconv.Atoi(strings.TrimSpace(value)) |
| 766 | if err != nil || n < 1 { |
| 767 | return 0 |
| 768 | } |
| 769 | return n |
| 770 | } |
| 771 | |
| 772 | func parseMemoryTime(value string) time.Time { |
| 773 | when, _ := time.Parse(time.RFC3339Nano, strings.TrimSpace(value)) |
| 774 | return when |
| 775 | } |
| 776 | |
| 777 | func persistedFactType(fm map[string]string) Type { |
| 778 | if t := Type(strings.ToLower(strings.TrimSpace(fm["fact_type"]))); validTypes[t] { |
| 779 | return t |
| 780 | } |
| 781 | return NormalizeType(fm["type"]) |
| 782 | } |
| 783 | |
| 784 | func factScopeFromFrontmatter(s string) FactScope { |
| 785 | switch FactScope(strings.ToLower(strings.TrimSpace(s))) { |
| 786 | case FactScopeProject: |
| 787 | return FactScopeProject |
| 788 | case FactScopeGlobal: |
| 789 | return FactScopeGlobal |
| 790 | default: |
| 791 | return "" |
| 792 | } |
| 793 | } |
| 794 | |
| 795 | func (s Store) scopeForDir(dir string) FactScope { |
| 796 | if s.GlobalDir != "" && sameDir(dir, s.GlobalDir) { |
| 797 | return FactScopeGlobal |
| 798 | } |
| 799 | return FactScopeProject |
| 800 | } |
| 801 | |
| 802 | func (s Store) scopeForPath(path string) FactScope { |
| 803 | return s.scopeForDir(filepath.Dir(path)) |
| 804 | } |
| 805 | |
| 806 | // splitFrontmatter is a thin wrapper; the real parser lives in |
| 807 | // internal/frontmatter. |
| 808 | func splitFrontmatter(s string) (map[string]string, string) { |
| 809 | return frontmatter.Split(s) |
| 810 | } |
| 811 | |
| 812 | // slugRe strips everything but Unicode letters and digits. |
| 813 | var slugRe = regexp.MustCompile(`[^\p{L}\p{N}]+`) |
| 814 | |
| 815 | // slug normalises a name into a kebab-case, filesystem-safe stem. The stem is |
| 816 | // bounded so `<stem>.md` stays under the 255-byte filename component limit — |
| 817 | // a name distilled from a long title/description previously failed the write |
| 818 | // with ENAMETOOLONG. Names short enough to have ever been written are |
| 819 | // returned unchanged, so existing files keep resolving. |
| 820 | func slug(s string) string { |
| 821 | stem := strings.Trim(slugRe.ReplaceAllString(strings.ToLower(strings.TrimSpace(s)), "-"), "-") |
| 822 | return config.BoundFilenameComponent(stem, 255-len(".md")) |
| 823 | } |
| 824 | |
| 825 | // oneLine collapses whitespace so a description can't break the single-line |
| 826 | // index or frontmatter format. |
| 827 | func oneLine(s string) string { |
| 828 | return strings.Join(strings.Fields(s), " ") |
| 829 | } |
| 830 | |
| 831 | // displayTitle is the index link label: the given title, or a de-kebabed name |
| 832 | // when none was supplied, so a bare slug never leaks into the index. |
| 833 | func displayTitle(title, name string) string { |
| 834 | if t := oneLine(title); t != "" { |
| 835 | return t |
| 836 | } |
| 837 | return strings.ReplaceAll(name, "-", " ") |
| 838 | } |
| 839 |