返回 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 := removeMemoryFromDir(existingPath, existing.Name); err != nil {
258 return SaveResult{}, err
259 }
260 }
261 if err := reindexIn(dir, m.Name, m); err != nil {
262 return SaveResult{Path: path, Memory: m}, err
263 }
264 // Legacy unqualified name updates keep the previous single-active-copy
265 // behavior. Stable IDs and scope-qualified references select one identity
266 // exactly, so they must not remove a same-named fact in the other scope.
267 if inputID == "" && !inputRef.qualified {
268 for _, otherDir := range s.dirs() {
269 if sameDir(otherDir, dir) {
270 continue
271 }
272 if duplicate, _, ok := s.findActiveInDir(otherDir, m.Name); ok && duplicate.ID != m.ID {
273 if _, err := archiveMemoryInDir(otherDir, duplicate.Name); err != nil {
274 return SaveResult{}, err
275 }
276 }
277 }
278 }
279
280 result := SaveResult{Path: path, Memory: m}
281 if exists {
282 previous := existing
283 result.Previous = &previous
284 }
285 return result, nil
286 }
287
288 func (s Store) Read(ref string) (Memory, bool) {
289 memory, _, ok := s.findActive(ref)
290 return memory, ok
291 }
292
293 func (s Store) findActive(ref string) (Memory, string, bool) {
294 parsed := parseMemoryReference(ref)
295 if parsed.raw == "" {
296 return Memory{}, "", false
297 }
298 if parsed.qualified {
299 return s.findActiveInDir(s.DirFor(parsed.scope), parsed.raw)
300 }
301 for _, v := range slices.Backward(s.dirs()) {
302 dir := v
303 if memory, path, ok := s.findActiveInDir(dir, parsed.raw); ok {
304 return memory, path, true
305 }
306 }
307 return Memory{}, "", false
308 }
309
310 func (s Store) findActiveInDir(dir, ref string) (Memory, string, bool) {
311 if strings.TrimSpace(dir) == "" {
312 return Memory{}, "", false
313 }
314 entries, err := os.ReadDir(dir)
315 if err != nil {
316 return Memory{}, "", false
317 }
318 parsed := parseMemoryReference(ref)
319 wantName := parsed.name
320 for _, entry := range entries {
321 if entry.IsDir() || entry.Name() == indexFile || !strings.HasSuffix(entry.Name(), ".md") {
322 continue
323 }
324 path := filepath.Join(dir, entry.Name())
325 memory, ok := loadMemory(path)
326 if !ok {
327 continue
328 }
329 if memory.Scope == "" {
330 memory.Scope = s.scopeForDir(dir)
331 }
332 if memory.ID == parsed.raw || slug(memory.Name) == wantName {
333 memory.Name = slug(memory.Name)
334 return memory, path, true
335 }
336 }
337 return Memory{}, "", false
338 }
339
340 type memoryReference struct {
341 raw string
342 name string
343 scope FactScope
344 qualified bool
345 }
346
347 // parseMemoryReference understands provider-visible references without ever
348 // treating them as filesystem paths. Memory facts are flat files, so only one
349 // fixed scope component plus one filename is accepted as a qualified form.
350 func parseMemoryReference(ref string) memoryReference {
351 raw := strings.TrimSpace(ref)
352 parsed := memoryReference{raw: raw, name: slug(strings.TrimSuffix(raw, ".md"))}
353 for _, candidate := range []FactScope{FactScopeProject, FactScopeGlobal} {
354 prefix := string(candidate) + "/"
355 if !strings.HasPrefix(raw, prefix) {
356 continue
357 }
358 name := strings.TrimPrefix(raw, prefix)
359 if name == "" || strings.ContainsAny(name, `/\\`) {
360 return parsed
361 }
362 parsed.name = slug(strings.TrimSuffix(name, ".md"))
363 parsed.scope = candidate
364 parsed.qualified = true
365 return parsed
366 }
367 return parsed
368 }
369
370 func (s Store) Revisions(ref string) []Memory {
371 active, _, ok := s.findActive(ref)
372 if !ok {
373 return nil
374 }
375 seen := map[int]bool{}
376 var revisions []Memory
377 for _, dir := range s.dirs() {
378 revisionDir := filepath.Join(dir, ".revisions", active.ID)
379 entries, err := os.ReadDir(revisionDir)
380 if err != nil {
381 continue
382 }
383 for _, entry := range entries {
384 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
385 continue
386 }
387 memory, ok := loadMemory(filepath.Join(revisionDir, entry.Name()))
388 if !ok || memory.ID != active.ID || seen[memory.Revision] {
389 continue
390 }
391 seen[memory.Revision] = true
392 revisions = append(revisions, memory)
393 }
394 }
395 sort.Slice(revisions, func(i, j int) bool { return revisions[i].Revision > revisions[j].Revision })
396 return revisions
397 }
398
399 func (s Store) Restore(ref string, revision int) (SaveResult, error) {
400 active, ok := s.Read(ref)
401 if !ok {
402 return SaveResult{}, fmt.Errorf("memory %q not found", ref)
403 }
404 if revision == active.Revision {
405 return SaveResult{Path: s.Path(active.Name), Memory: active}, nil
406 }
407 var target Memory
408 found := false
409 for _, candidate := range s.Revisions(active.ID) {
410 if candidate.Revision == revision {
411 target = candidate
412 found = true
413 break
414 }
415 }
416 if !found {
417 return SaveResult{}, fmt.Errorf("memory %q revision %d not found", active.ID, revision)
418 }
419 target.ID = active.ID
420 return s.SaveWithOptions(target, SaveOptions{ExpectedRevision: active.Revision, RequireExpectedRevision: true})
421 }
422
423 // RestoreArchived recovers one archive entry as a new active revision. The
424 // archive path must be an entry currently owned by this Store. Recovery never
425 // overwrites an active identity or slug, and the archived state becomes an
426 // immutable revision snapshot before the new active file is created.
427 func (s Store) RestoreArchived(archivePath string) (SaveResult, error) {
428 memoryStoreMutationMu.Lock()
429 defer memoryStoreMutationMu.Unlock()
430
431 archivePath = cleanMemoryPath(strings.TrimSpace(archivePath))
432 archived, base, ok := s.findArchivedByPath(archivePath)
433 if !ok {
434 return SaveResult{}, fmt.Errorf("archived memory not found")
435 }
436 if active, _, exists := s.findActive(archived.ID); exists {
437 return SaveResult{}, fmt.Errorf("memory id %q is already active as %q", archived.ID, active.Name)
438 }
439 if active, _, exists := s.findActive(archived.Name); exists {
440 return SaveResult{}, fmt.Errorf("memory name %q is already active as id %q", archived.Name, active.ID)
441 }
442
443 if err := snapshotMemoryRevisionInDir(base, archivePath, archived); err != nil {
444 return SaveResult{}, err
445 }
446 now := time.Now().UTC()
447 restored := archived
448 restored.Scope = s.scopeForDir(base)
449 restored.Revision = s.maxKnownRevision(archived.ID) + 1
450 if restored.Revision <= archived.Revision {
451 restored.Revision = archived.Revision + 1
452 }
453 if restored.CreatedAt.IsZero() {
454 restored.CreatedAt = now
455 }
456 restored.UpdatedAt = now
457 path, err := safeJoin(base, restored.Name+".md")
458 if err != nil {
459 return SaveResult{}, err
460 }
461 if err := writeMemoryCreate(path, []byte(render(restored, restored.Name)), 0o644); err != nil {
462 if os.IsExist(err) {
463 return SaveResult{}, fmt.Errorf("memory name %q is already active", restored.Name)
464 }
465 return SaveResult{}, err
466 }
467 if err := reindexIn(base, restored.Name, restored); err != nil {
468 return SaveResult{Path: path, Memory: restored}, err
469 }
470 if err := os.Remove(archivePath); err != nil && !os.IsNotExist(err) {
471 return SaveResult{Path: path, Memory: restored}, err
472 }
473 return SaveResult{Path: path, Memory: restored}, nil
474 }
475
476 func (s Store) findArchivedByPath(want string) (Memory, string, bool) {
477 for _, base := range s.dirs() {
478 if strings.TrimSpace(base) == "" {
479 continue
480 }
481 dir := filepath.Join(base, ".archive")
482 info, err := os.Lstat(dir)
483 if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
484 continue
485 }
486 entries, err := os.ReadDir(dir)
487 if err != nil {
488 continue
489 }
490 for _, entry := range entries {
491 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
492 continue
493 }
494 path, err := safeJoin(dir, entry.Name())
495 if err != nil || cleanMemoryPath(path) != want {
496 continue
497 }
498 info, err := os.Lstat(path)
499 if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
500 return Memory{}, "", false
501 }
502 archived, ok := loadMemory(path)
503 if !ok {
504 return Memory{}, "", false
505 }
506 if archived.Scope == "" {
507 archived.Scope = s.scopeForDir(base)
508 }
509 archived.Name = slug(archived.Name)
510 return archived, base, true
511 }
512 }
513 return Memory{}, "", false
514 }
515
516 func (s Store) maxKnownRevision(id string) int {
517 maxRevision := 0
518 for _, base := range s.dirs() {
519 if strings.TrimSpace(base) == "" {
520 continue
521 }
522 for _, dir := range []string{filepath.Join(base, ".archive"), filepath.Join(base, ".revisions", id)} {
523 entries, err := os.ReadDir(dir)
524 if err != nil {
525 continue
526 }
527 for _, entry := range entries {
528 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
529 continue
530 }
531 path := filepath.Join(dir, entry.Name())
532 info, err := os.Lstat(path)
533 if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
534 continue
535 }
536 candidate, ok := loadMemory(path)
537 if ok && candidate.ID == id && candidate.Revision > maxRevision {
538 maxRevision = candidate.Revision
539 }
540 }
541 }
542 }
543 return maxRevision
544 }
545
546 func snapshotMemoryRevision(path string, memory Memory) error {
547 return snapshotMemoryRevisionInDir(filepath.Dir(path), path, memory)
548 }
549
550 func snapshotMemoryRevisionInDir(base, path string, memory Memory) error {
551 if memory.ID == "" || memory.Revision < 1 {
552 return nil
553 }
554 b, err := os.ReadFile(path)
555 if err != nil {
556 return err
557 }
558 dir := filepath.Join(base, ".revisions", memory.ID)
559 if err := os.MkdirAll(dir, 0o755); err != nil {
560 return err
561 }
562 name := fmt.Sprintf("%09d.md", memory.Revision)
563 return writeMemoryAtomic(filepath.Join(dir, name), b, 0o644)
564 }
565
566 // writeMemoryAtomic publishes a fact file through the shared crash-safe
567 // writer (temp + fsync + replace), creating the parent directory on demand.
568 func writeMemoryAtomic(path string, data []byte, mode os.FileMode) error {
569 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
570 return err
571 }
572 return fileutil.AtomicWriteFile(path, data, mode)
573 }
574
575 // writeMemoryCreate publishes a fact file only when path is still absent; a
576 // concurrent creator wins. The shared writer stages a complete temp file, so
577 // a crash can never leave a partial fact where active truth lives.
578 func writeMemoryCreate(path string, data []byte, mode os.FileMode) error {
579 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
580 return err
581 }
582 return fileutil.AtomicCreateFile(path, data, mode)
583 }
584
585 func newMemoryID(name string, now time.Time) string {
586 raw := make([]byte, 16)
587 if _, err := rand.Read(raw); err == nil {
588 return "mem-" + hex.EncodeToString(raw)
589 }
590 sum := sha256.Sum256([]byte(name + "\x00" + strconv.FormatInt(now.UnixNano(), 10)))
591 return "mem-" + hex.EncodeToString(sum[:16])
592 }
593
594 func legacyMemoryID(name string, scope FactScope) string {
595 sum := sha256.Sum256([]byte("reasonix-memory-v2\x00" + string(NormalizeFactScope(string(scope))) + "\x00" + slug(name)))
596 return "legacy-" + hex.EncodeToString(sum[:12])
597 }
598
599 func cleanMemoryPath(path string) string {
600 abs, err := filepath.Abs(path)
601 if err != nil {
602 return filepath.Clean(path)
603 }
604 return filepath.Clean(abs)
605 }
606
606 lines GO