返回 DeepSeek-Reasonix
store_v2.go
根目录 / internal / memory / store_v2.go
1 package memory
2
3 import (
4 "crypto/rand"
5 "crypto/sha256"
6 "encoding/hex"
7 "fmt"
8 "os"
9 "path/filepath"
10 "slices"
11 "sort"
12 "strconv"
13 "strings"
14 "sync"
15 "time"
16 "unicode/utf8"
17
18 "reasonix/internal/fileutil"
19 )
20
21 type SaveOptions struct {
22 ExpectedRevision int
23 RequireExpectedRevision bool
24 RequireCreate bool
25 ClearExpiry bool // drop an inherited expires_at instead of preserving it
26 }
27
28 type SaveResult struct {
29 Path string
30 Memory Memory
31 Previous *Memory
32 }
33
34 type MigrationReport struct {
35 Migrated int
36 }
37
38 var memoryStoreMutationMu sync.Mutex
39
40 func (s Store) MigrateV2() (MigrationReport, error) {
41 memoryStoreMutationMu.Lock()
42 defer memoryStoreMutationMu.Unlock()
43 var report MigrationReport
44 for _, dir := range s.dirs() {
45 if strings.TrimSpace(dir) == "" {
46 continue
47 }
48 info, err := os.Stat(dir)
49 if os.IsNotExist(err) {
50 continue
51 }
52 if err != nil {
53 return report, err
54 }
55 if !info.IsDir() {
56 return report, fmt.Errorf("memory store path %q is not a directory", dir)
57 }
58 entries, err := os.ReadDir(dir)
59 if err != nil {
60 return report, err
61 }
62 for _, entry := range entries {
63 if entry.IsDir() || entry.Name() == indexFile || !strings.HasSuffix(entry.Name(), ".md") {
64 continue
65 }
66 path := filepath.Join(dir, entry.Name())
67 raw, err := os.ReadFile(path)
68 if err != nil {
69 return report, err
70 }
71 frontmatter, _ := splitFrontmatter(string(raw))
72 if strings.TrimSpace(frontmatter["id"]) != "" && parsePositiveInt(frontmatter["revision"]) > 0 {
73 continue
74 }
75 memory, ok := loadMemory(path)
76 if !ok {
77 continue
78 }
79 memory.Name = slug(memory.Name)
80 if memory.Scope == "" {
81 memory.Scope = s.scopeForDir(dir)
82 }
83 if err := writeMemoryAtomic(path, []byte(render(memory, memory.Name)), 0o644); err != nil {
84 return report, err
85 }
86 if err := reindexIn(dir, memory.Name, memory); err != nil {
87 return report, err
88 }
89 report.Migrated++
90 }
91 }
92 return report, nil
93 }
94
95 // inheritOnUpdate keeps the update-omittable fields of an existing revision:
96 // an update that leaves scope, activation, volatility, expiry, verification,
97 // or keywords empty preserves them, it does not clear them. ClearExpiry is
98 // the explicit exception — dropping a boundary must be a stated intent.
99 func inheritOnUpdate(m Memory, existing Memory, clearExpiry bool) Memory {
100 if strings.TrimSpace(string(m.Scope)) == "" {
101 m.Scope = existing.Scope
102 }
103 if NormalizeActivation(string(m.Activation)) == "" {
104 m.Activation = existing.Activation
105 }
106 if NormalizeVolatility(string(m.Volatility)) == "" {
107 m.Volatility = existing.Volatility
108 }
109 if NormalizeSubjectKey(m.SubjectKey) == "" {
110 m.SubjectKey = existing.SubjectKey
111 }
112 if clearExpiry {
113 m.ExpiresAt = time.Time{}
114 } else if m.ExpiresAt.IsZero() {
115 m.ExpiresAt = existing.ExpiresAt
116 }
117 if m.LastVerifiedAt.IsZero() {
118 m.LastVerifiedAt = existing.LastVerifiedAt
119 }
120 if strings.TrimSpace(m.Keywords) == "" {
121 m.Keywords = existing.Keywords
122 }
123 return m
124 }
125
126 // validateSave runs the cross-fact invariants once identity, scope, and
127 // inheritance are resolved: the pinned budget and subject uniqueness.
128 func (s Store) validateSave(m Memory) error {
129 if err := s.validatePinnedBudget(m); err != nil {
130 return err
131 }
132 return s.validateSubjectKey(m)
133 }
134
135 // validatePinnedBudget rejects a save that would push the total pinned-body
136 // runes over PinnedGuidanceBudgetChars. Legacy virtually-pinned guidance
137 // counts — it occupies the same prefix — so an over-budget store forces
138 // curation before anything new can be pinned.
139 func (s Store) validatePinnedBudget(m Memory) error {
140 if ResolveActivation(m) != ActivationPinned {
141 return nil
142 }
143 total := utf8.RuneCountInString(strings.TrimSpace(m.Body))
144 for _, pinned := range s.pinnedGuidance() {
145 if pinned.ID == m.ID || (m.ID == "" && pinned.Name == m.Name) {
146 continue
147 }
148 total += utf8.RuneCountInString(strings.TrimSpace(pinned.Body))
149 }
150 if total <= PinnedGuidanceBudgetChars {
151 return nil
152 }
153 return fmt.Errorf("pinning this fact would put pinned guidance at %d chars, over the %d budget: rules that must always hold belong in REASONIX.md/AGENTS.md instructions; unpin or consolidate existing pinned facts first", total, PinnedGuidanceBudgetChars)
154 }
155
156 func (s Store) SaveWithOptions(m Memory, opts SaveOptions) (SaveResult, error) {
157 memoryStoreMutationMu.Lock()
158 defer memoryStoreMutationMu.Unlock()
159
160 inputID := strings.TrimSpace(m.ID)
161 inputRef := parseMemoryReference(m.Name)
162 if inputID == "" && inputRef.qualified && strings.TrimSpace(string(m.Scope)) != "" &&
163 NormalizeFactScope(string(m.Scope)) != inputRef.scope {
164 return SaveResult{}, fmt.Errorf("memory reference scope %q conflicts with explicit scope %q", inputRef.scope, m.Scope)
165 }
166 var existing Memory
167 var existingPath string
168 var exists bool
169 if inputID != "" {
170 existing, existingPath, exists = s.findActive(inputID)
171 if !exists {
172 return SaveResult{}, fmt.Errorf("memory id %q not found", m.ID)
173 }
174 } else if inputRef.raw != "" {
175 existing, existingPath, exists = s.findActive(m.Name)
176 }
177 if opts.RequireExpectedRevision {
178 actual := 0
179 if exists {
180 actual = existing.Revision
181 }
182 if actual != opts.ExpectedRevision {
183 return SaveResult{}, fmt.Errorf("memory revision conflict: expected %d, found %d", opts.ExpectedRevision, actual)
184 }
185 }
186 if opts.RequireCreate && exists {
187 return SaveResult{}, fmt.Errorf("memory %q already exists; automatic writes are create-only", existing.Name)
188 }
189
190 if inputRef.raw == "" {
191 if !exists {
192 return SaveResult{}, fmt.Errorf("memory needs a name")
193 }
194 m.Name = existing.Name
195 } else if exists && inputID == "" {
196 // Name-based references identify an existing fact; renames require its
197 // stable ID. This also prevents display references such as foo.md or
198 // project/foo.md from becoming new slugs during an update.
199 m.Name = existing.Name
200 } else {
201 m.Name = inputRef.name
202 }
203 m.Name = slug(m.Name)
204 if m.Name == "" {
205 return SaveResult{}, fmt.Errorf("memory name needs at least one letter or digit")
206 }
207 now := time.Now().UTC()
208 if exists {
209 m.ID, m.Revision, m.CreatedAt = existing.ID, existing.Revision+1, existing.CreatedAt
210 m = inheritOnUpdate(m, existing, opts.ClearExpiry)
211 } else {
212 m.ID = newMemoryID(m.Name, now)
213 m.Revision = 1
214 m.CreatedAt = now
215 }
216 if m.CreatedAt.IsZero() {
217 m.CreatedAt = now
218 }
219 m.UpdatedAt = now
220 m.Type = NormalizeType(string(m.Type))
221 if strings.TrimSpace(string(m.Scope)) == "" {
222 if inputRef.qualified {
223 m.Scope = inputRef.scope
224 } else {
225 m.Scope = FactScopeProject
226 }
227 } else {
228 m.Scope = NormalizeFactScope(string(m.Scope))
229 }
230 if err := s.validateSave(m); err != nil {
231 return SaveResult{}, err
232 }
233
234 dir := s.DirFor(m.Scope)
235 if dir == "" {
236 return SaveResult{}, fmt.Errorf("memory store unavailable (no user config dir)")
237 }
238 if err := os.MkdirAll(dir, 0o755); err != nil {
239 return SaveResult{}, err
240 }
241 if collision, _, ok := s.findActiveInDir(dir, m.Name); ok && (!exists || collision.ID != existing.ID) {
242 return SaveResult{}, fmt.Errorf("memory name %q is already used by id %q", m.Name, collision.ID)
243 }
244 path, err := safeJoin(dir, m.Name+".md")
245 if err != nil {
246 return SaveResult{}, err
247 }
248 if exists {
249 if err := snapshotMemoryRevision(existingPath, existing); err != nil {
250 return SaveResult{}, err
251 }
252 }
253 if err := writeMemoryAtomic(path, []byte(render(m, m.Name)), 0o644); err != nil {
254 return SaveResult{}, err
255 }
256 if exists && cleanMemoryPath(existingPath) != cleanMemoryPath(path) {
257 if err := os.Remove(existingPath); err != nil && !os.IsNotExist(err) {
258 return SaveResult{}, err
259 }
260 oldDir := filepath.Dir(existingPath)
261 if err := flushIndexIn(oldDir, indexLinesExceptIn(oldDir, existing.Name)); err != nil {
262 return SaveResult{}, err
263 }
264 }
265 if err := reindexIn(dir, m.Name, m); err != nil {
266 return SaveResult{Path: path, Memory: m}, err
267 }
268 // Legacy unqualified name updates keep the previous single-active-copy
269 // behavior. Stable IDs and scope-qualified references select one identity
270 // exactly, so they must not remove a same-named fact in the other scope.
271 if inputID == "" && !inputRef.qualified {
272 for _, otherDir := range s.dirs() {
273 if sameDir(otherDir, dir) {
274 continue
275 }
276 if duplicate, _, ok := s.findActiveInDir(otherDir, m.Name); ok && duplicate.ID != m.ID {
277 if _, err := archiveInDir(otherDir, duplicate.Name); err != nil {
278 return SaveResult{}, err
279 }
280 if err := flushIndexIn(otherDir, indexLinesExceptIn(otherDir, duplicate.Name)); err != nil {
281 return SaveResult{}, err
282 }
283 }
284 }
285 }
286
287 result := SaveResult{Path: path, Memory: m}
288 if exists {
289 previous := existing
290 result.Previous = &previous
291 }
292 return result, nil
293 }
294
295 func (s Store) Read(ref string) (Memory, bool) {
296 memory, _, ok := s.findActive(ref)
297 return memory, ok
298 }
299
300 func (s Store) findActive(ref string) (Memory, string, bool) {
301 parsed := parseMemoryReference(ref)
302 if parsed.raw == "" {
303 return Memory{}, "", false
304 }
305 if parsed.qualified {
306 return s.findActiveInDir(s.DirFor(parsed.scope), parsed.raw)
307 }
308 for _, v := range slices.Backward(s.dirs()) {
309 dir := v
310 if memory, path, ok := s.findActiveInDir(dir, parsed.raw); ok {
311 return memory, path, true
312 }
313 }
314 return Memory{}, "", false
315 }
316
317 func (s Store) findActiveInDir(dir, ref string) (Memory, string, bool) {
318 if strings.TrimSpace(dir) == "" {
319 return Memory{}, "", false
320 }
321 entries, err := os.ReadDir(dir)
322 if err != nil {
323 return Memory{}, "", false
324 }
325 parsed := parseMemoryReference(ref)
326 wantName := parsed.name
327 for _, entry := range entries {
328 if entry.IsDir() || entry.Name() == indexFile || !strings.HasSuffix(entry.Name(), ".md") {
329 continue
330 }
331 path := filepath.Join(dir, entry.Name())
332 memory, ok := loadMemory(path)
333 if !ok {
334 continue
335 }
336 if memory.Scope == "" {
337 memory.Scope = s.scopeForDir(dir)
338 }
339 if memory.ID == parsed.raw || slug(memory.Name) == wantName {
340 memory.Name = slug(memory.Name)
341 return memory, path, true
342 }
343 }
344 return Memory{}, "", false
345 }
346
347 type memoryReference struct {
348 raw string
349 name string
350 scope FactScope
351 qualified bool
352 }
353
354 // parseMemoryReference understands provider-visible references without ever
355 // treating them as filesystem paths. Memory facts are flat files, so only one
356 // fixed scope component plus one filename is accepted as a qualified form.
357 func parseMemoryReference(ref string) memoryReference {
358 raw := strings.TrimSpace(ref)
359 parsed := memoryReference{raw: raw, name: slug(strings.TrimSuffix(raw, ".md"))}
360 for _, candidate := range []FactScope{FactScopeProject, FactScopeGlobal} {
361 prefix := string(candidate) + "/"
362 if !strings.HasPrefix(raw, prefix) {
363 continue
364 }
365 name := strings.TrimPrefix(raw, prefix)
366 if name == "" || strings.ContainsAny(name, `/\\`) {
367 return parsed
368 }
369 parsed.name = slug(strings.TrimSuffix(name, ".md"))
370 parsed.scope = candidate
371 parsed.qualified = true
372 return parsed
373 }
374 return parsed
375 }
376
377 func (s Store) Revisions(ref string) []Memory {
378 active, _, ok := s.findActive(ref)
379 if !ok {
380 return nil
381 }
382 seen := map[int]bool{}
383 var revisions []Memory
384 for _, dir := range s.dirs() {
385 revisionDir := filepath.Join(dir, ".revisions", active.ID)
386 entries, err := os.ReadDir(revisionDir)
387 if err != nil {
388 continue
389 }
390 for _, entry := range entries {
391 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
392 continue
393 }
394 memory, ok := loadMemory(filepath.Join(revisionDir, entry.Name()))
395 if !ok || memory.ID != active.ID || seen[memory.Revision] {
396 continue
397 }
398 seen[memory.Revision] = true
399 revisions = append(revisions, memory)
400 }
401 }
402 sort.Slice(revisions, func(i, j int) bool { return revisions[i].Revision > revisions[j].Revision })
403 return revisions
404 }
405
406 func (s Store) Restore(ref string, revision int) (SaveResult, error) {
407 active, ok := s.Read(ref)
408 if !ok {
409 return SaveResult{}, fmt.Errorf("memory %q not found", ref)
410 }
411 if revision == active.Revision {
412 return SaveResult{Path: s.Path(active.Name), Memory: active}, nil
413 }
414 var target Memory
415 found := false
416 for _, candidate := range s.Revisions(active.ID) {
417 if candidate.Revision == revision {
418 target = candidate
419 found = true
420 break
421 }
422 }
423 if !found {
424 return SaveResult{}, fmt.Errorf("memory %q revision %d not found", active.ID, revision)
425 }
426 target.ID = active.ID
427 return s.SaveWithOptions(target, SaveOptions{ExpectedRevision: active.Revision, RequireExpectedRevision: true})
428 }
429
430 // RestoreArchived recovers one archive entry as a new active revision. The
431 // archive path must be an entry currently owned by this Store. Recovery never
432 // overwrites an active identity or slug, and the archived state becomes an
433 // immutable revision snapshot before the new active file is created.
434 func (s Store) RestoreArchived(archivePath string) (SaveResult, error) {
435 memoryStoreMutationMu.Lock()
436 defer memoryStoreMutationMu.Unlock()
437
438 archivePath = cleanMemoryPath(strings.TrimSpace(archivePath))
439 archived, base, ok := s.findArchivedByPath(archivePath)
440 if !ok {
441 return SaveResult{}, fmt.Errorf("archived memory not found")
442 }
443 if active, _, exists := s.findActive(archived.ID); exists {
444 return SaveResult{}, fmt.Errorf("memory id %q is already active as %q", archived.ID, active.Name)
445 }
446 if active, _, exists := s.findActive(archived.Name); exists {
447 return SaveResult{}, fmt.Errorf("memory name %q is already active as id %q", archived.Name, active.ID)
448 }
449
450 if err := snapshotMemoryRevisionInDir(base, archivePath, archived); err != nil {
451 return SaveResult{}, err
452 }
453 now := time.Now().UTC()
454 restored := archived
455 restored.Scope = s.scopeForDir(base)
456 restored.Revision = s.maxKnownRevision(archived.ID) + 1
457 if restored.Revision <= archived.Revision {
458 restored.Revision = archived.Revision + 1
459 }
460 if restored.CreatedAt.IsZero() {
461 restored.CreatedAt = now
462 }
463 restored.UpdatedAt = now
464 path, err := safeJoin(base, restored.Name+".md")
465 if err != nil {
466 return SaveResult{}, err
467 }
468 if err := writeMemoryCreate(path, []byte(render(restored, restored.Name)), 0o644); err != nil {
469 if os.IsExist(err) {
470 return SaveResult{}, fmt.Errorf("memory name %q is already active", restored.Name)
471 }
472 return SaveResult{}, err
473 }
474 if err := reindexIn(base, restored.Name, restored); err != nil {
475 return SaveResult{Path: path, Memory: restored}, err
476 }
477 if err := os.Remove(archivePath); err != nil && !os.IsNotExist(err) {
478 return SaveResult{Path: path, Memory: restored}, err
479 }
480 return SaveResult{Path: path, Memory: restored}, nil
481 }
482
483 func (s Store) findArchivedByPath(want string) (Memory, string, bool) {
484 for _, base := range s.dirs() {
485 if strings.TrimSpace(base) == "" {
486 continue
487 }
488 dir := filepath.Join(base, ".archive")
489 info, err := os.Lstat(dir)
490 if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
491 continue
492 }
493 entries, err := os.ReadDir(dir)
494 if err != nil {
495 continue
496 }
497 for _, entry := range entries {
498 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
499 continue
500 }
501 path, err := safeJoin(dir, entry.Name())
502 if err != nil || cleanMemoryPath(path) != want {
503 continue
504 }
505 info, err := os.Lstat(path)
506 if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
507 return Memory{}, "", false
508 }
509 archived, ok := loadMemory(path)
510 if !ok {
511 return Memory{}, "", false
512 }
513 if archived.Scope == "" {
514 archived.Scope = s.scopeForDir(base)
515 }
516 archived.Name = slug(archived.Name)
517 return archived, base, true
518 }
519 }
520 return Memory{}, "", false
521 }
522
523 func (s Store) maxKnownRevision(id string) int {
524 maxRevision := 0
525 for _, base := range s.dirs() {
526 if strings.TrimSpace(base) == "" {
527 continue
528 }
529 for _, dir := range []string{filepath.Join(base, ".archive"), filepath.Join(base, ".revisions", id)} {
530 entries, err := os.ReadDir(dir)
531 if err != nil {
532 continue
533 }
534 for _, entry := range entries {
535 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
536 continue
537 }
538 path := filepath.Join(dir, entry.Name())
539 info, err := os.Lstat(path)
540 if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
541 continue
542 }
543 candidate, ok := loadMemory(path)
544 if ok && candidate.ID == id && candidate.Revision > maxRevision {
545 maxRevision = candidate.Revision
546 }
547 }
548 }
549 }
550 return maxRevision
551 }
552
553 func snapshotMemoryRevision(path string, memory Memory) error {
554 return snapshotMemoryRevisionInDir(filepath.Dir(path), path, memory)
555 }
556
557 func snapshotMemoryRevisionInDir(base, path string, memory Memory) error {
558 if memory.ID == "" || memory.Revision < 1 {
559 return nil
560 }
561 b, err := os.ReadFile(path)
562 if err != nil {
563 return err
564 }
565 dir := filepath.Join(base, ".revisions", memory.ID)
566 if err := os.MkdirAll(dir, 0o755); err != nil {
567 return err
568 }
569 name := fmt.Sprintf("%09d.md", memory.Revision)
570 return writeMemoryAtomic(filepath.Join(dir, name), b, 0o644)
571 }
572
573 // writeMemoryAtomic publishes a fact file through the shared crash-safe
574 // writer (temp + fsync + replace), creating the parent directory on demand.
575 func writeMemoryAtomic(path string, data []byte, mode os.FileMode) error {
576 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
577 return err
578 }
579 return fileutil.AtomicWriteFile(path, data, mode)
580 }
581
582 // writeMemoryCreate publishes a fact file only when path is still absent; a
583 // concurrent creator wins. The shared writer stages a complete temp file, so
584 // a crash can never leave a partial fact where active truth lives.
585 func writeMemoryCreate(path string, data []byte, mode os.FileMode) error {
586 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
587 return err
588 }
589 return fileutil.AtomicCreateFile(path, data, mode)
590 }
591
592 func newMemoryID(name string, now time.Time) string {
593 raw := make([]byte, 16)
594 if _, err := rand.Read(raw); err == nil {
595 return "mem-" + hex.EncodeToString(raw)
596 }
597 sum := sha256.Sum256([]byte(name + "\x00" + strconv.FormatInt(now.UnixNano(), 10)))
598 return "mem-" + hex.EncodeToString(sum[:16])
599 }
600
601 func legacyMemoryID(name string, scope FactScope) string {
602 sum := sha256.Sum256([]byte("reasonix-memory-v2\x00" + string(NormalizeFactScope(string(scope))) + "\x00" + slug(name)))
603 return "legacy-" + hex.EncodeToString(sum[:12])
604 }
605
606 func cleanMemoryPath(path string) string {
607 abs, err := filepath.Abs(path)
608 if err != nil {
609 return filepath.Clean(path)
610 }
611 return filepath.Clean(abs)
612 }
613
613 lines GO