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