context-mcp-server 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -359
- package/codegraph/server.py +37 -46
- package/package.json +4 -3
- package/pyproject.toml +1 -1
- package/src/assests/main.png +0 -0
- package/src/cli.js +69 -57
- package/src/db.js +946 -798
- package/src/guard.js +9 -3
- package/src/migrator.js +124 -0
- package/src/server.js +7 -6
- package/src/templates/AGENTS.md +6 -13
- package/src/templates/CLAUDE.md +6 -12
- package/src/templates/GEMINI.md +6 -13
- package/src/templates/commands/context-resume.md +1 -1
- package/src/templates/commands/save-context.md +1 -1
- package/src/templates/cursor-rules.mdc +3 -3
- package/src/templates/skills/SKILL.md +9 -16
- package/src/templates/windsurf-rules.md +3 -3
- package/src/tools/codegraph.js +8 -77
- package/src/tools/context.js +23 -11
- package/src/tools/gitTools.js +1 -3
- package/src/tools/plan.js +130 -0
- package/uv.lock +1 -1
- package/codegraph/extractors/audio_extractor.py +0 -8
- package/codegraph/extractors/doc_extractor.py +0 -34
- package/codegraph/extractors/image_extractor.py +0 -26
- package/src/tools/discussion.js +0 -123
package/src/db.js
CHANGED
|
@@ -1,798 +1,946 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* db.js —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const WRITE_DEBOUNCE_MS
|
|
32
|
-
const LOCK_WAIT_TIMEOUT_MS = 2000;
|
|
33
|
-
|
|
34
|
-
const _isWin = platform() === 'win32';
|
|
35
|
-
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
entry
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
return
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
if (
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
if (
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
.
|
|
734
|
-
.
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1
|
+
/**
|
|
2
|
+
* db.js — per-project directory store for context-mcp
|
|
3
|
+
*
|
|
4
|
+
* Layout:
|
|
5
|
+
* ~/.context-mcp/
|
|
6
|
+
* ├── projects.json ← master index
|
|
7
|
+
* └── projects/
|
|
8
|
+
* └── <slug>/
|
|
9
|
+
* ├── context.json ← decision, bug, note, code, config, error
|
|
10
|
+
* ├── graph.json ← { build: {...}, entries: [...architecture...] }
|
|
11
|
+
* ├── summary.json ← summary type + archived entries
|
|
12
|
+
* └── discussions.json
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
readFileSync, writeFileSync, mkdirSync, existsSync,
|
|
17
|
+
openSync, closeSync, unlinkSync, renameSync, chmodSync, rmdirSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { homedir, platform } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
import { runMigration } from './migrator.js';
|
|
23
|
+
|
|
24
|
+
const DATA_DIR = process.env.CONTEXT_MCP_DIR || join(homedir(), '.context-mcp');
|
|
25
|
+
const PROJECTS_DIR = join(DATA_DIR, 'projects');
|
|
26
|
+
const PROJECTS_PATH = join(DATA_DIR, 'projects.json');
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
const MAX_CONTENT_LENGTH = 5000;
|
|
30
|
+
const PREVIEW_LENGTH = 200;
|
|
31
|
+
const WRITE_DEBOUNCE_MS = 500;
|
|
32
|
+
const LOCK_WAIT_TIMEOUT_MS = 2000;
|
|
33
|
+
|
|
34
|
+
const _isWin = platform() === 'win32';
|
|
35
|
+
|
|
36
|
+
function normPath(p) {
|
|
37
|
+
return p ? p.toLowerCase().replace(/\\/g, '/').replace(/\/$/, '') : '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function slugify(name) {
|
|
41
|
+
return name.toLowerCase().replace(/[^a-z0-9_-]/g, '_');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function projectDataDir(name) { return join(PROJECTS_DIR, slugify(name)); }
|
|
45
|
+
function contextFilePath(name) { return join(projectDataDir(name), 'context.json'); }
|
|
46
|
+
function graphFilePath(name) { return join(projectDataDir(name), 'graph.json'); }
|
|
47
|
+
function summaryFilePath(name) { return join(projectDataDir(name), 'summary.json'); }
|
|
48
|
+
function discussFilePath(name) { return join(projectDataDir(name), 'discussions.json'); }
|
|
49
|
+
|
|
50
|
+
function treeFor(entry) {
|
|
51
|
+
if (entry.type === 'compaction') return 'summary';
|
|
52
|
+
return 'context';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _secureFile(p) {
|
|
56
|
+
if (_isWin) return;
|
|
57
|
+
try { chmodSync(p, 0o600); } catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!existsSync(DATA_DIR)) {
|
|
61
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
62
|
+
if (!_isWin) { try { chmodSync(DATA_DIR, 0o700); } catch {} }
|
|
63
|
+
}
|
|
64
|
+
if (!existsSync(PROJECTS_DIR)) {
|
|
65
|
+
mkdirSync(PROJECTS_DIR, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── In-memory cache ──────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
let _projectsIndex = null; // array of { id, name, rootPath, createdAt, dataDir }
|
|
71
|
+
let _projectsIndexDirty = false;
|
|
72
|
+
let _projectData = new Map(); // name -> { context: [], graph: { build, entries: [] }, summary: [], discussions: [] }
|
|
73
|
+
let _dirtyProjects = new Set();
|
|
74
|
+
let _dirty = false;
|
|
75
|
+
let _writeTimer = null;
|
|
76
|
+
let _generation = 0;
|
|
77
|
+
let _migrated = false;
|
|
78
|
+
|
|
79
|
+
// ── File I/O helpers ─────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function _flushFile(filePath, content) {
|
|
82
|
+
const lockPath = `${filePath}.lock`;
|
|
83
|
+
const tmpPath = `${filePath}.tmp`;
|
|
84
|
+
let lockFd;
|
|
85
|
+
let renamed = false;
|
|
86
|
+
try {
|
|
87
|
+
const started = Date.now();
|
|
88
|
+
for (;;) {
|
|
89
|
+
try { lockFd = openSync(lockPath, 'wx'); break; }
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (err && err.code !== 'EEXIST') throw err;
|
|
92
|
+
if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS)
|
|
93
|
+
throw new Error(`Timed out waiting for lock: ${lockPath}`);
|
|
94
|
+
const t = Date.now(); while (Date.now() - t < 10) {}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
writeFileSync(tmpPath, JSON.stringify(content, null, 2), 'utf8');
|
|
98
|
+
_secureFile(tmpPath);
|
|
99
|
+
renameSync(tmpPath, filePath);
|
|
100
|
+
renamed = true;
|
|
101
|
+
} finally {
|
|
102
|
+
if (lockFd !== undefined) { closeSync(lockFd); try { unlinkSync(lockPath); } catch {} }
|
|
103
|
+
try { if (!renamed && existsSync(tmpPath)) unlinkSync(tmpPath); } catch {}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function _readArr(filePath, key) {
|
|
108
|
+
if (!existsSync(filePath)) return [];
|
|
109
|
+
try {
|
|
110
|
+
const d = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
111
|
+
return Array.isArray(d[key]) ? d[key] : (Array.isArray(d) ? d : []);
|
|
112
|
+
} catch { return []; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function _readObj(filePath, defaults) {
|
|
116
|
+
if (!existsSync(filePath)) return { ...defaults };
|
|
117
|
+
try { return { ...defaults, ...JSON.parse(readFileSync(filePath, 'utf8')) }; }
|
|
118
|
+
catch { return { ...defaults }; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Projects index ───────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function loadProjectsIndex() {
|
|
124
|
+
if (_projectsIndex) return _projectsIndex;
|
|
125
|
+
if (!existsSync(PROJECTS_PATH)) { _projectsIndex = []; return _projectsIndex; }
|
|
126
|
+
try {
|
|
127
|
+
const d = JSON.parse(readFileSync(PROJECTS_PATH, 'utf8'));
|
|
128
|
+
_projectsIndex = Array.isArray(d.projects) ? d.projects : [];
|
|
129
|
+
} catch { _projectsIndex = []; }
|
|
130
|
+
return _projectsIndex;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Migration ─────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
function migrate() {
|
|
136
|
+
if (_migrated) return;
|
|
137
|
+
_migrated = true;
|
|
138
|
+
runMigration({
|
|
139
|
+
dataDir: DATA_DIR,
|
|
140
|
+
projectsDir: PROJECTS_DIR,
|
|
141
|
+
projectsPath: PROJECTS_PATH,
|
|
142
|
+
slugify,
|
|
143
|
+
flushFile: _flushFile,
|
|
144
|
+
projectsIndex: loadProjectsIndex(),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Per-project data loading ─────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function loadProjectData(name) {
|
|
151
|
+
if (_projectData.has(name)) return _projectData.get(name);
|
|
152
|
+
const dir = projectDataDir(name);
|
|
153
|
+
mkdirSync(dir, { recursive: true });
|
|
154
|
+
const data = {
|
|
155
|
+
context: _readArr(contextFilePath(name), 'entries'),
|
|
156
|
+
graph: _readObj(graphFilePath(name), { build: null }),
|
|
157
|
+
summary: _readArr(summaryFilePath(name), 'entries'),
|
|
158
|
+
discussions: _readArr(discussFilePath(name), 'discussions'),
|
|
159
|
+
};
|
|
160
|
+
_projectData.set(name, data);
|
|
161
|
+
return data;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getAllEntries(projectName) {
|
|
165
|
+
const data = loadProjectData(projectName);
|
|
166
|
+
return [...data.context, ...data.summary];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Find an entry by ID, optionally scoped to a project.
|
|
170
|
+
function findEntryById(id, projectHint) {
|
|
171
|
+
const search = (data) => {
|
|
172
|
+
for (const arr of [data.context, data.summary]) {
|
|
173
|
+
const e = arr.find(c => c.id === id);
|
|
174
|
+
if (e) return e;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
};
|
|
178
|
+
if (projectHint) {
|
|
179
|
+
const e = search(loadProjectData(projectHint));
|
|
180
|
+
if (e) return { entry: e, projectName: projectHint };
|
|
181
|
+
}
|
|
182
|
+
for (const [name, data] of _projectData.entries()) {
|
|
183
|
+
if (name === projectHint) continue;
|
|
184
|
+
const e = search(data);
|
|
185
|
+
if (e) return { entry: e, projectName: name };
|
|
186
|
+
}
|
|
187
|
+
// Load all remaining projects
|
|
188
|
+
const idx = loadProjectsIndex();
|
|
189
|
+
for (const proj of idx) {
|
|
190
|
+
if (_projectData.has(proj.name) || proj.name === projectHint) continue;
|
|
191
|
+
const e = search(loadProjectData(proj.name));
|
|
192
|
+
if (e) return { entry: e, projectName: proj.name };
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Remove an entry from its array in the project data.
|
|
198
|
+
function removeEntryFromData(data, entry) {
|
|
199
|
+
if (treeFor(entry) === 'summary') {
|
|
200
|
+
data.summary = data.summary.filter(e => e.id !== entry.id);
|
|
201
|
+
} else {
|
|
202
|
+
data.context = data.context.filter(e => e.id !== entry.id);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Dirty tracking & flush ───────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
function markDirty() {
|
|
209
|
+
_dirty = true;
|
|
210
|
+
_generation++;
|
|
211
|
+
if (_writeTimer) clearTimeout(_writeTimer);
|
|
212
|
+
_writeTimer = setTimeout(flushToDisk, WRITE_DEBOUNCE_MS);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function flushProjectToDisk(name) {
|
|
216
|
+
const data = _projectData.get(name);
|
|
217
|
+
if (!data) return;
|
|
218
|
+
const dir = projectDataDir(name);
|
|
219
|
+
mkdirSync(dir, { recursive: true });
|
|
220
|
+
_flushFile(contextFilePath(name), { entries: data.context });
|
|
221
|
+
_flushFile(graphFilePath(name), data.graph);
|
|
222
|
+
_flushFile(summaryFilePath(name), { entries: data.summary });
|
|
223
|
+
_flushFile(discussFilePath(name), { discussions: data.discussions });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function flushToDisk() {
|
|
227
|
+
if (!_dirty) return;
|
|
228
|
+
_writeTimer = null;
|
|
229
|
+
|
|
230
|
+
for (const name of _dirtyProjects) {
|
|
231
|
+
flushProjectToDisk(name);
|
|
232
|
+
}
|
|
233
|
+
_dirtyProjects.clear();
|
|
234
|
+
|
|
235
|
+
if (_projectsIndexDirty && _projectsIndex) {
|
|
236
|
+
_flushFile(PROJECTS_PATH, { projects: _projectsIndex });
|
|
237
|
+
_projectsIndexDirty = false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
_dirty = false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
process.on('exit', flushToDisk);
|
|
244
|
+
process.on('SIGINT', () => { flushToDisk(); process.exit(); });
|
|
245
|
+
process.on('SIGTERM', () => { flushToDisk(); process.exit(); });
|
|
246
|
+
|
|
247
|
+
// ── Initialise: run migration lazily on first access ─────────────────────────
|
|
248
|
+
|
|
249
|
+
function init() {
|
|
250
|
+
loadProjectsIndex();
|
|
251
|
+
migrate();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
function truncate(text, max) {
|
|
257
|
+
if (!text || text.length <= max) return text;
|
|
258
|
+
return text.slice(0, max - 3) + '...';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function normalizeTags(tags) {
|
|
262
|
+
if (Array.isArray(tags)) return tags;
|
|
263
|
+
if (typeof tags === 'string') return tags.split(',').map(t => t.trim()).filter(Boolean);
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const VALID_SOURCES = new Set(['user', 'ai-summary', 'file', 'web', 'cli', 'auto']);
|
|
268
|
+
function normalizeSource(s) { return VALID_SOURCES.has(s) ? s : 'user'; }
|
|
269
|
+
|
|
270
|
+
function compactEntry(e) {
|
|
271
|
+
const compact = {
|
|
272
|
+
id: e.id,
|
|
273
|
+
project: e.project,
|
|
274
|
+
sessionId: e.sessionId,
|
|
275
|
+
nodeType: e.nodeType || 'entry',
|
|
276
|
+
title: e.title || '',
|
|
277
|
+
type: e.type || 'note',
|
|
278
|
+
status: e.status || 'active',
|
|
279
|
+
version: e.version || 1,
|
|
280
|
+
tags: e.tags,
|
|
281
|
+
source: e.source,
|
|
282
|
+
createdAt: e.createdAt,
|
|
283
|
+
updatedAt: e.updatedAt || null,
|
|
284
|
+
preview: truncate(e.content, PREVIEW_LENGTH),
|
|
285
|
+
};
|
|
286
|
+
if (e.files && e.files.length) compact.files = e.files;
|
|
287
|
+
if (e.codeRefs && e.codeRefs.length) compact.codeRefs = e.codeRefs;
|
|
288
|
+
if (e.expiresAt) compact.expiresAt = e.expiresAt;
|
|
289
|
+
return compact;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Context entries ──────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
export function saveContext({ project, content, tags = [], source = 'user', title = '',
|
|
295
|
+
type = 'note', status = 'active', files = [], codeRefs = [],
|
|
296
|
+
sessionId = null, parentId = null, expiresAt = null, rootPath = null }) {
|
|
297
|
+
init();
|
|
298
|
+
const projectName = project || 'global';
|
|
299
|
+
ensureProject(projectName, rootPath || undefined);
|
|
300
|
+
const data = loadProjectData(projectName);
|
|
301
|
+
const now = new Date().toISOString();
|
|
302
|
+
const entry = {
|
|
303
|
+
id: randomUUID(),
|
|
304
|
+
project: projectName,
|
|
305
|
+
sessionId: sessionId || null,
|
|
306
|
+
parentId: parentId || sessionId || `project:${projectName}`,
|
|
307
|
+
nodeType: 'entry',
|
|
308
|
+
version: 1,
|
|
309
|
+
title: truncate(title, 60),
|
|
310
|
+
content: truncate(content, MAX_CONTENT_LENGTH),
|
|
311
|
+
type,
|
|
312
|
+
status,
|
|
313
|
+
tags: normalizeTags(tags),
|
|
314
|
+
source: normalizeSource(source),
|
|
315
|
+
files: Array.isArray(files) ? files : [],
|
|
316
|
+
codeRefs: Array.isArray(codeRefs) ? codeRefs : [],
|
|
317
|
+
discussionId: null,
|
|
318
|
+
createdAt: now,
|
|
319
|
+
updatedAt: null,
|
|
320
|
+
expiresAt: expiresAt || null,
|
|
321
|
+
};
|
|
322
|
+
const tree = treeFor(entry);
|
|
323
|
+
if (tree === 'graph') data.graph.entries.push(entry);
|
|
324
|
+
else if (tree === 'summary') data.summary.push(entry);
|
|
325
|
+
else data.context.push(entry);
|
|
326
|
+
_dirtyProjects.add(projectName);
|
|
327
|
+
markDirty();
|
|
328
|
+
return entry;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function updateContext({ id, content, title, tags, type, status, files, codeRefs, sessionId, parentId, expiresAt }) {
|
|
332
|
+
init();
|
|
333
|
+
const found = findEntryById(id);
|
|
334
|
+
if (!found) return null;
|
|
335
|
+
const { entry, projectName } = found;
|
|
336
|
+
const data = loadProjectData(projectName);
|
|
337
|
+
|
|
338
|
+
const oldTree = treeFor(entry);
|
|
339
|
+
if (content !== undefined) entry.content = truncate(content, MAX_CONTENT_LENGTH);
|
|
340
|
+
if (title !== undefined) entry.title = truncate(title, 60);
|
|
341
|
+
if (tags !== undefined) entry.tags = normalizeTags(tags);
|
|
342
|
+
if (type !== undefined) entry.type = type;
|
|
343
|
+
if (status !== undefined) entry.status = status;
|
|
344
|
+
if (files !== undefined) entry.files = Array.isArray(files) ? files : [];
|
|
345
|
+
if (codeRefs !== undefined) entry.codeRefs = Array.isArray(codeRefs) ? codeRefs : [];
|
|
346
|
+
if (expiresAt !== undefined) entry.expiresAt = expiresAt || null;
|
|
347
|
+
if (sessionId !== undefined) entry.sessionId = sessionId || null;
|
|
348
|
+
if (parentId !== undefined) entry.parentId = parentId || entry.sessionId || `project:${entry.project || 'global'}`;
|
|
349
|
+
entry.version = (entry.version || 1) + 1;
|
|
350
|
+
entry.updatedAt = new Date().toISOString();
|
|
351
|
+
|
|
352
|
+
// Re-route if type/status changed tree membership
|
|
353
|
+
const newTree = treeFor(entry);
|
|
354
|
+
if (newTree !== oldTree) {
|
|
355
|
+
removeEntryFromData(data, entry);
|
|
356
|
+
// Re-add with updated tree
|
|
357
|
+
const tempEntry = { ...entry };
|
|
358
|
+
if (newTree === 'graph') data.graph.entries.push(tempEntry);
|
|
359
|
+
else if (newTree === 'summary') data.summary.push(tempEntry);
|
|
360
|
+
else data.context.push(tempEntry);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
_dirtyProjects.add(projectName);
|
|
364
|
+
markDirty();
|
|
365
|
+
return entry;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function getContext({ project, tags, limit = 20, compact = false, ids } = {}) {
|
|
369
|
+
init();
|
|
370
|
+
|
|
371
|
+
if (ids && ids.length) {
|
|
372
|
+
const idSet = new Set(ids);
|
|
373
|
+
// Load all projects to find entries
|
|
374
|
+
const idx = loadProjectsIndex();
|
|
375
|
+
const all = [];
|
|
376
|
+
const loaded = new Set(_projectData.keys());
|
|
377
|
+
for (const proj of idx) loaded.add(proj.name);
|
|
378
|
+
for (const name of loaded) {
|
|
379
|
+
for (const e of getAllEntries(name)) {
|
|
380
|
+
if (idSet.has(e.id)) all.push(e);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return compact ? all.map(compactEntry) : all;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let results;
|
|
387
|
+
if (project) {
|
|
388
|
+
const entries = getAllEntries(project);
|
|
389
|
+
const globalEntries = project !== 'global' ? getAllEntries('global') : [];
|
|
390
|
+
results = [...entries, ...globalEntries];
|
|
391
|
+
} else {
|
|
392
|
+
// No project filter: load all
|
|
393
|
+
const idx = loadProjectsIndex();
|
|
394
|
+
const all = [];
|
|
395
|
+
const seen = new Set(_projectData.keys());
|
|
396
|
+
for (const proj of idx) seen.add(proj.name);
|
|
397
|
+
for (const name of seen) {
|
|
398
|
+
all.push(...getAllEntries(name));
|
|
399
|
+
}
|
|
400
|
+
results = all;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (tags && tags.length) {
|
|
404
|
+
const tagList = Array.isArray(tags) ? tags : tags.split(',').map(t => t.trim());
|
|
405
|
+
results = results.filter(c => tagList.some(t => Array.isArray(c.tags) && c.tags.includes(t)));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Sort by createdAt ascending, then take last `limit`
|
|
409
|
+
results.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
410
|
+
const sliced = results.slice(-limit).reverse();
|
|
411
|
+
return compact ? sliced.map(compactEntry) : sliced;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function getContextSince(since, project) {
|
|
415
|
+
init();
|
|
416
|
+
let results;
|
|
417
|
+
if (project) {
|
|
418
|
+
results = [...getAllEntries(project)];
|
|
419
|
+
if (project !== 'global') results.push(...getAllEntries('global'));
|
|
420
|
+
} else {
|
|
421
|
+
const idx = loadProjectsIndex();
|
|
422
|
+
results = [];
|
|
423
|
+
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
424
|
+
for (const name of seen) results.push(...getAllEntries(name));
|
|
425
|
+
}
|
|
426
|
+
return results.filter(c => c.createdAt >= since);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function searchContext({ query, project, limit = 10, compact = false }) {
|
|
430
|
+
init();
|
|
431
|
+
const terms = query.toLowerCase().split(/\s+/);
|
|
432
|
+
let results;
|
|
433
|
+
if (project) {
|
|
434
|
+
results = [...getAllEntries(project)];
|
|
435
|
+
if (project !== 'global') results.push(...getAllEntries('global'));
|
|
436
|
+
} else {
|
|
437
|
+
const idx = loadProjectsIndex();
|
|
438
|
+
results = [];
|
|
439
|
+
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
440
|
+
for (const name of seen) results.push(...getAllEntries(name));
|
|
441
|
+
}
|
|
442
|
+
const scored = results.map(c => {
|
|
443
|
+
const haystack = `${c.title || ''} ${c.content || ''} ${(Array.isArray(c.tags) ? c.tags : []).join(' ')}`.toLowerCase();
|
|
444
|
+
const score = terms.reduce((s, t) => s + (haystack.split(t).length - 1), 0);
|
|
445
|
+
return { ...c, score };
|
|
446
|
+
}).filter(c => c.score > 0).sort((a, b) => b.score - a.score);
|
|
447
|
+
const sliced = scored.slice(0, limit).map(({ score, ...c }) => c);
|
|
448
|
+
return compact ? sliced.map(compactEntry) : sliced;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function deleteContext({ id, ids }) {
|
|
452
|
+
init();
|
|
453
|
+
const idSet = new Set(ids && ids.length ? ids : (id ? [id] : []));
|
|
454
|
+
if (!idSet.size) return { deleted: 0 };
|
|
455
|
+
let deleted = 0;
|
|
456
|
+
// Scan all loaded projects
|
|
457
|
+
const seen = new Set(_projectData.keys());
|
|
458
|
+
loadProjectsIndex().forEach(p => seen.add(p.name));
|
|
459
|
+
for (const name of seen) {
|
|
460
|
+
const data = loadProjectData(name);
|
|
461
|
+
const allEntries = getAllEntries(name);
|
|
462
|
+
const toRemove = allEntries.filter(e => idSet.has(e.id));
|
|
463
|
+
if (!toRemove.length) continue;
|
|
464
|
+
for (const entry of toRemove) removeEntryFromData(data, entry);
|
|
465
|
+
_dirtyProjects.add(name);
|
|
466
|
+
deleted += toRemove.length;
|
|
467
|
+
if (deleted >= idSet.size) break;
|
|
468
|
+
}
|
|
469
|
+
if (deleted > 0) markDirty();
|
|
470
|
+
return { deleted };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function deleteProject(nameOrId) {
|
|
474
|
+
init();
|
|
475
|
+
const idx = loadProjectsIndex();
|
|
476
|
+
const byId = idx.find(p => p.id === nameOrId);
|
|
477
|
+
const projectName = byId ? byId.name : nameOrId;
|
|
478
|
+
|
|
479
|
+
// Count before removing
|
|
480
|
+
const data = _projectData.get(projectName) || loadProjectData(projectName);
|
|
481
|
+
const ctxCount = data.context.length + data.graph.entries.length + data.summary.length;
|
|
482
|
+
const discCount = data.discussions.length;
|
|
483
|
+
|
|
484
|
+
// Remove project directory from disk
|
|
485
|
+
const dir = projectDataDir(projectName);
|
|
486
|
+
if (existsSync(dir)) {
|
|
487
|
+
for (const file of ['context.json', 'graph.json', 'summary.json', 'discussions.json']) {
|
|
488
|
+
try { unlinkSync(join(dir, file)); } catch {}
|
|
489
|
+
}
|
|
490
|
+
try { rmdirSync(dir); } catch {}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Drop from cache
|
|
494
|
+
_projectData.delete(projectName);
|
|
495
|
+
_dirtyProjects.delete(projectName);
|
|
496
|
+
|
|
497
|
+
// Remove from index
|
|
498
|
+
const beforeProj = idx.length;
|
|
499
|
+
_projectsIndex = idx.filter(p => p.name !== projectName);
|
|
500
|
+
if (_projectsIndex.length !== beforeProj) {
|
|
501
|
+
_projectsIndexDirty = true;
|
|
502
|
+
markDirty();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return { deletedEntries: ctxCount, deletedDiscussions: discCount };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function countContext(project) {
|
|
509
|
+
init();
|
|
510
|
+
if (!project) {
|
|
511
|
+
const idx = loadProjectsIndex();
|
|
512
|
+
let total = 0;
|
|
513
|
+
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
514
|
+
for (const name of seen) total += getAllEntries(name).length;
|
|
515
|
+
return total;
|
|
516
|
+
}
|
|
517
|
+
return getAllEntries(project).length;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function ensureProject(name, rootPath) {
|
|
521
|
+
if (!name || name === 'global') return null;
|
|
522
|
+
const idx = loadProjectsIndex();
|
|
523
|
+
let proj = idx.find(p => p.name === name);
|
|
524
|
+
if (!proj) {
|
|
525
|
+
proj = {
|
|
526
|
+
id: randomUUID(),
|
|
527
|
+
name,
|
|
528
|
+
createdAt: new Date().toISOString(),
|
|
529
|
+
dataDir: `projects/${slugify(name)}`,
|
|
530
|
+
};
|
|
531
|
+
idx.push(proj);
|
|
532
|
+
_projectsIndexDirty = true;
|
|
533
|
+
markDirty();
|
|
534
|
+
}
|
|
535
|
+
if (rootPath && !proj.rootPath) {
|
|
536
|
+
proj.rootPath = rootPath;
|
|
537
|
+
if (!proj.dataDir) proj.dataDir = `projects/${slugify(name)}`;
|
|
538
|
+
_projectsIndexDirty = true;
|
|
539
|
+
markDirty();
|
|
540
|
+
}
|
|
541
|
+
return proj;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function getProjectRoot(name) {
|
|
545
|
+
if (!name || name === 'global') return null;
|
|
546
|
+
init();
|
|
547
|
+
return loadProjectsIndex().find(p => p.name === name)?.rootPath || null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function listProjects() {
|
|
551
|
+
init();
|
|
552
|
+
const idx = loadProjectsIndex();
|
|
553
|
+
// Load all known project dirs to get entry counts
|
|
554
|
+
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
555
|
+
return [...seen]
|
|
556
|
+
.map(name => {
|
|
557
|
+
const count = getAllEntries(name).length;
|
|
558
|
+
const reg = idx.find(p => p.name === name);
|
|
559
|
+
if (!reg && count === 0) return null;
|
|
560
|
+
return {
|
|
561
|
+
id: reg?.id || null,
|
|
562
|
+
name,
|
|
563
|
+
count,
|
|
564
|
+
createdAt: reg?.createdAt || null,
|
|
565
|
+
rootPath: reg?.rootPath || null,
|
|
566
|
+
};
|
|
567
|
+
})
|
|
568
|
+
.filter(p => p && p.count > 0);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ── Auto-dedup ───────────────────────────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
export function findDuplicate(content, project) {
|
|
574
|
+
init();
|
|
575
|
+
const existing = getContext({ project, limit: 50 });
|
|
576
|
+
if (!existing.length) return null;
|
|
577
|
+
const newWords = new Set(content.toLowerCase().split(/\s+/).filter(w => w.length > 3));
|
|
578
|
+
if (!newWords.size) return null;
|
|
579
|
+
for (const entry of existing) {
|
|
580
|
+
const oldWords = new Set((entry.content || '').toLowerCase().split(/\s+/).filter(w => w.length > 3));
|
|
581
|
+
if (!oldWords.size) continue;
|
|
582
|
+
const overlap = [...newWords].filter(w => oldWords.has(w)).length;
|
|
583
|
+
const similarity = overlap / Math.max(newWords.size, oldWords.size);
|
|
584
|
+
if (similarity > 0.85) return entry;
|
|
585
|
+
}
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ── Discussions ───────────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
const VALID_DISCUSSION_TYPES = new Set(['plan','research','idea','design','implementation','review','thread']);
|
|
592
|
+
const VALID_DISCUSSION_STATUSES = new Set(['active','done']);
|
|
593
|
+
|
|
594
|
+
export function saveDiscussion({ name, title, description, content, project, tags,
|
|
595
|
+
type, status, steps, linkedContextIds, parentId, sessionId }) {
|
|
596
|
+
init();
|
|
597
|
+
const proj = project || 'global';
|
|
598
|
+
const data = loadProjectData(proj);
|
|
599
|
+
const existing = data.discussions.findIndex(d => d.name === name);
|
|
600
|
+
const now = new Date().toISOString();
|
|
601
|
+
const prev = existing >= 0 ? data.discussions[existing] : null;
|
|
602
|
+
const disc = {
|
|
603
|
+
id: prev?.id || randomUUID(),
|
|
604
|
+
name,
|
|
605
|
+
project: project !== undefined ? (project || 'global') : (prev?.project ?? 'global'),
|
|
606
|
+
sessionId: sessionId !== undefined ? (sessionId || null) : (prev?.sessionId ?? null),
|
|
607
|
+
parentId: parentId !== undefined ? (parentId || null) : (prev?.parentId ?? null),
|
|
608
|
+
title: title !== undefined ? truncate(title || name, 80) : (prev?.title ?? name),
|
|
609
|
+
description: description !== undefined ? (description || '') : (prev?.description ?? ''),
|
|
610
|
+
content: content !== undefined ? truncate(content || '', MAX_CONTENT_LENGTH) : (prev?.content ?? ''),
|
|
611
|
+
type: type !== undefined ? (VALID_DISCUSSION_TYPES.has(type) ? type : 'plan') : (prev?.type ?? 'plan'),
|
|
612
|
+
status: status !== undefined ? (VALID_DISCUSSION_STATUSES.has(status) ? status : 'active') : (prev?.status ?? 'active'),
|
|
613
|
+
tags: tags !== undefined ? normalizeTags(tags) : (prev?.tags ?? []),
|
|
614
|
+
steps: steps !== undefined ? mergeSteps(prev?.steps ?? [], steps) : (prev?.steps ?? []),
|
|
615
|
+
linkedContextIds: linkedContextIds !== undefined ? (Array.isArray(linkedContextIds) ? linkedContextIds : []) : (prev?.linkedContextIds ?? []),
|
|
616
|
+
createdAt: prev?.createdAt || now,
|
|
617
|
+
updatedAt: now,
|
|
618
|
+
};
|
|
619
|
+
if (existing >= 0) data.discussions[existing] = disc;
|
|
620
|
+
else data.discussions.push(disc);
|
|
621
|
+
_dirtyProjects.add(proj);
|
|
622
|
+
markDirty();
|
|
623
|
+
return disc;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function mergeSteps(prevSteps, incomingSteps) {
|
|
627
|
+
if (!Array.isArray(incomingSteps) || incomingSteps.length === 0) return prevSteps;
|
|
628
|
+
return incomingSteps.map((s, i) => {
|
|
629
|
+
const prev = prevSteps.find(p => p.id && p.id === s.id) || prevSteps[i];
|
|
630
|
+
return {
|
|
631
|
+
id: s.id || prev?.id || randomUUID(),
|
|
632
|
+
title: s.title ?? prev?.title ?? '',
|
|
633
|
+
description: s.description ?? prev?.description ?? '',
|
|
634
|
+
status: s.status ?? prev?.status ?? 'pending',
|
|
635
|
+
order: s.order ?? prev?.order ?? i,
|
|
636
|
+
linkedContextIds: s.linkedContextIds ?? prev?.linkedContextIds ?? [],
|
|
637
|
+
completedAt: s.completedAt ?? prev?.completedAt ?? null,
|
|
638
|
+
};
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function updateDiscussion({ id, name, title, description, content, status, type, tags, steps, linkedContextIds, parentId, sessionId }) {
|
|
643
|
+
init();
|
|
644
|
+
let disc = null;
|
|
645
|
+
let projName = null;
|
|
646
|
+
const idx = loadProjectsIndex();
|
|
647
|
+
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
648
|
+
for (const pName of seen) {
|
|
649
|
+
const d = loadProjectData(pName);
|
|
650
|
+
const found = id ? d.discussions.find(x => x.id === id) : d.discussions.find(x => x.name === name);
|
|
651
|
+
if (found) { disc = found; projName = pName; break; }
|
|
652
|
+
}
|
|
653
|
+
if (!disc) return null;
|
|
654
|
+
if (title !== undefined) disc.title = truncate(title || disc.name, 80);
|
|
655
|
+
if (description !== undefined) disc.description = description || '';
|
|
656
|
+
if (content !== undefined) disc.content = truncate(content || '', MAX_CONTENT_LENGTH);
|
|
657
|
+
if (type !== undefined) disc.type = VALID_DISCUSSION_TYPES.has(type) ? type : disc.type;
|
|
658
|
+
if (status !== undefined) disc.status = VALID_DISCUSSION_STATUSES.has(status) ? status : disc.status;
|
|
659
|
+
if (tags !== undefined) disc.tags = normalizeTags(tags);
|
|
660
|
+
if (steps !== undefined) disc.steps = mergeSteps(disc.steps ?? [], steps);
|
|
661
|
+
if (linkedContextIds !== undefined) disc.linkedContextIds = Array.isArray(linkedContextIds) ? linkedContextIds : disc.linkedContextIds;
|
|
662
|
+
if (parentId !== undefined) disc.parentId = parentId || null;
|
|
663
|
+
if (sessionId !== undefined) disc.sessionId = sessionId || null;
|
|
664
|
+
disc.updatedAt = new Date().toISOString();
|
|
665
|
+
_dirtyProjects.add(projName);
|
|
666
|
+
markDirty();
|
|
667
|
+
return disc;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function getDiscussion({ project, name, id } = {}) {
|
|
671
|
+
init();
|
|
672
|
+
if (project) {
|
|
673
|
+
const data = loadProjectData(project);
|
|
674
|
+
let list = data.discussions;
|
|
675
|
+
if (id) return list.find(d => d.id === id) || null;
|
|
676
|
+
if (name) return list.find(d => d.name === name) || null;
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
// Search all
|
|
680
|
+
const idx = loadProjectsIndex();
|
|
681
|
+
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
682
|
+
for (const pName of seen) {
|
|
683
|
+
const d = loadProjectData(pName);
|
|
684
|
+
const found = id ? d.discussions.find(x => x.id === id) : d.discussions.find(x => x.name === name);
|
|
685
|
+
if (found) return found;
|
|
686
|
+
}
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export function listDiscussions({ project, status, type } = {}) {
|
|
691
|
+
init();
|
|
692
|
+
let list = [];
|
|
693
|
+
if (project) {
|
|
694
|
+
list = loadProjectData(project).discussions;
|
|
695
|
+
if (project !== 'global') list = [...list, ...loadProjectData('global').discussions];
|
|
696
|
+
} else {
|
|
697
|
+
const idx = loadProjectsIndex();
|
|
698
|
+
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
699
|
+
for (const pName of seen) list.push(...loadProjectData(pName).discussions);
|
|
700
|
+
}
|
|
701
|
+
if (status) list = list.filter(d => d.status === status);
|
|
702
|
+
if (type) list = list.filter(d => d.type === type);
|
|
703
|
+
return list.map(({ content: _, steps, ...rest }) => ({
|
|
704
|
+
...rest,
|
|
705
|
+
stepsSummary: {
|
|
706
|
+
total: (steps || []).length,
|
|
707
|
+
done: (steps || []).filter(s => s.status === 'done').length,
|
|
708
|
+
inProgress: (steps || []).filter(s => s.status === 'in-progress').length,
|
|
709
|
+
},
|
|
710
|
+
}));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function linkContextToDiscussion({ discussionId, discussionName, contextId }) {
|
|
714
|
+
init();
|
|
715
|
+
// Find discussion across projects
|
|
716
|
+
let disc = null;
|
|
717
|
+
let discProject = null;
|
|
718
|
+
const idx = loadProjectsIndex();
|
|
719
|
+
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
720
|
+
for (const pName of seen) {
|
|
721
|
+
const d = loadProjectData(pName);
|
|
722
|
+
const found = discussionId
|
|
723
|
+
? d.discussions.find(x => x.id === discussionId)
|
|
724
|
+
: d.discussions.find(x => x.name === discussionName);
|
|
725
|
+
if (found) { disc = found; discProject = pName; break; }
|
|
726
|
+
}
|
|
727
|
+
if (!disc) return null;
|
|
728
|
+
|
|
729
|
+
if (!Array.isArray(disc.linkedContextIds)) disc.linkedContextIds = [];
|
|
730
|
+
let changed = false;
|
|
731
|
+
if (!disc.linkedContextIds.includes(contextId)) {
|
|
732
|
+
disc.linkedContextIds.push(contextId);
|
|
733
|
+
disc.updatedAt = new Date().toISOString();
|
|
734
|
+
_dirtyProjects.add(discProject);
|
|
735
|
+
changed = true;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Write discussionId back onto the context entry
|
|
739
|
+
const found = findEntryById(contextId);
|
|
740
|
+
if (found && found.entry.discussionId !== disc.id) {
|
|
741
|
+
found.entry.discussionId = disc.id;
|
|
742
|
+
found.entry.updatedAt = new Date().toISOString();
|
|
743
|
+
_dirtyProjects.add(found.projectName);
|
|
744
|
+
changed = true;
|
|
745
|
+
}
|
|
746
|
+
if (changed) markDirty();
|
|
747
|
+
return { discussionId: disc.id, contextId };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
export function getContextByDiscussion(discussionId) {
|
|
751
|
+
init();
|
|
752
|
+
const idx = loadProjectsIndex();
|
|
753
|
+
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
754
|
+
const results = [];
|
|
755
|
+
for (const name of seen) {
|
|
756
|
+
results.push(...getAllEntries(name).filter(c => c.discussionId === discussionId));
|
|
757
|
+
}
|
|
758
|
+
return results;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
export function clearDiscussionLink(contextId) {
|
|
762
|
+
init();
|
|
763
|
+
const found = findEntryById(contextId);
|
|
764
|
+
if (!found) return null;
|
|
765
|
+
found.entry.discussionId = null;
|
|
766
|
+
found.entry.updatedAt = new Date().toISOString();
|
|
767
|
+
_dirtyProjects.add(found.projectName);
|
|
768
|
+
markDirty();
|
|
769
|
+
return found.entry;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export function deleteDiscussion({ name, id }) {
|
|
773
|
+
init();
|
|
774
|
+
const idx = loadProjectsIndex();
|
|
775
|
+
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
776
|
+
for (const pName of seen) {
|
|
777
|
+
const data = loadProjectData(pName);
|
|
778
|
+
const before = data.discussions.length;
|
|
779
|
+
data.discussions = data.discussions.filter(d => {
|
|
780
|
+
if (id) return d.id !== id;
|
|
781
|
+
if (name) return d.name !== name;
|
|
782
|
+
return true;
|
|
783
|
+
});
|
|
784
|
+
if (data.discussions.length < before) {
|
|
785
|
+
_dirtyProjects.add(pName);
|
|
786
|
+
markDirty();
|
|
787
|
+
return { deleted: before - data.discussions.length };
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return { deleted: 0 };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
export function updateDiscussionStep({ discussionName, discussionId, stepId, status, linkedContextId }) {
|
|
794
|
+
init();
|
|
795
|
+
let disc = null;
|
|
796
|
+
let projName = null;
|
|
797
|
+
const idx = loadProjectsIndex();
|
|
798
|
+
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
799
|
+
for (const pName of seen) {
|
|
800
|
+
const d = loadProjectData(pName);
|
|
801
|
+
const found = discussionId
|
|
802
|
+
? d.discussions.find(x => x.id === discussionId)
|
|
803
|
+
: d.discussions.find(x => x.name === discussionName);
|
|
804
|
+
if (found) { disc = found; projName = pName; break; }
|
|
805
|
+
}
|
|
806
|
+
if (!disc) return null;
|
|
807
|
+
const step = (disc.steps || []).find(s => s.id === stepId);
|
|
808
|
+
if (!step) return null;
|
|
809
|
+
if (status) step.status = status;
|
|
810
|
+
if (status === 'done') step.completedAt = new Date().toISOString();
|
|
811
|
+
if (linkedContextId) {
|
|
812
|
+
if (!Array.isArray(step.linkedContextIds)) step.linkedContextIds = [];
|
|
813
|
+
if (!step.linkedContextIds.includes(linkedContextId)) step.linkedContextIds.push(linkedContextId);
|
|
814
|
+
if (!Array.isArray(disc.linkedContextIds)) disc.linkedContextIds = [];
|
|
815
|
+
if (!disc.linkedContextIds.includes(linkedContextId)) disc.linkedContextIds.push(linkedContextId);
|
|
816
|
+
}
|
|
817
|
+
const allDone = disc.steps.every(s => s.status === 'done' || s.status === 'skipped');
|
|
818
|
+
if (allDone && disc.status !== 'done') disc.status = 'done';
|
|
819
|
+
disc.updatedAt = new Date().toISOString();
|
|
820
|
+
_dirtyProjects.add(projName);
|
|
821
|
+
markDirty();
|
|
822
|
+
return { discussion: disc, step };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ── Auto-operations ───────────────────────────────────────────────────────────
|
|
826
|
+
|
|
827
|
+
export function archiveExpired(project) {
|
|
828
|
+
init();
|
|
829
|
+
const now = new Date().toISOString();
|
|
830
|
+
let count = 0;
|
|
831
|
+
const processEntries = (entries, projName) => {
|
|
832
|
+
for (const entry of entries) {
|
|
833
|
+
if (entry.expiresAt && entry.expiresAt < now && entry.status !== 'archived') {
|
|
834
|
+
entry.status = 'archived';
|
|
835
|
+
entry.updatedAt = now;
|
|
836
|
+
_dirtyProjects.add(projName);
|
|
837
|
+
count++;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
if (project) {
|
|
843
|
+
processEntries(getAllEntries(project), project);
|
|
844
|
+
} else {
|
|
845
|
+
const idx = loadProjectsIndex();
|
|
846
|
+
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
847
|
+
for (const name of seen) processEntries(getAllEntries(name).slice(), name);
|
|
848
|
+
}
|
|
849
|
+
if (count > 0) markDirty();
|
|
850
|
+
return { archived: count };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ── Exports ──────────────────────────────────────────────────────────────────
|
|
854
|
+
|
|
855
|
+
export function getStorePath() { return DATA_DIR; }
|
|
856
|
+
export function getGeneration() { return _generation; }
|
|
857
|
+
export function flushStore() { flushToDisk(); }
|
|
858
|
+
|
|
859
|
+
// ── Auto-compaction ───────────────────────────────────────────────────────────
|
|
860
|
+
|
|
861
|
+
const COMPACTION_THRESHOLD = 20;
|
|
862
|
+
const COMPACTION_TARGET = 30;
|
|
863
|
+
|
|
864
|
+
export function shouldCompact(project) {
|
|
865
|
+
return countContext(project) > COMPACTION_THRESHOLD;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export function compactProject(project, summaryContent) {
|
|
869
|
+
init();
|
|
870
|
+
const proj = project || 'global';
|
|
871
|
+
const data = loadProjectData(proj);
|
|
872
|
+
const entries = data.context
|
|
873
|
+
.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
874
|
+
if (entries.length < COMPACTION_TARGET) return null;
|
|
875
|
+
const toRemove = new Set(entries.slice(0, COMPACTION_TARGET).map(e => e.id));
|
|
876
|
+
const removed = entries.filter(e => toRemove.has(e.id));
|
|
877
|
+
for (const entry of removed) removeEntryFromData(data, entry);
|
|
878
|
+
_dirtyProjects.add(proj);
|
|
879
|
+
markDirty();
|
|
880
|
+
const summary = saveContext({
|
|
881
|
+
project: proj,
|
|
882
|
+
title: `Compacted ${removed.length} entries — ${new Date().toISOString().slice(0, 10)}`,
|
|
883
|
+
content: summaryContent,
|
|
884
|
+
type: 'compaction',
|
|
885
|
+
source: 'auto',
|
|
886
|
+
tags: ['compaction', 'auto'],
|
|
887
|
+
});
|
|
888
|
+
return { removedCount: removed.length, summaryId: summary.id };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ── Graph registry ────────────────────────────────────────────────────────────
|
|
892
|
+
|
|
893
|
+
export function saveGraph({ path, nodes, edges, communities, cached, changed, time_ms, summary }) {
|
|
894
|
+
init();
|
|
895
|
+
// Find project by rootPath matching graph path
|
|
896
|
+
const idx = loadProjectsIndex();
|
|
897
|
+
const proj = idx.find(p => normPath(p.rootPath) === normPath(path));
|
|
898
|
+
const projName = proj ? proj.name : 'global';
|
|
899
|
+
|
|
900
|
+
const data = loadProjectData(projName);
|
|
901
|
+
const existing = data.graph.build;
|
|
902
|
+
const record = {
|
|
903
|
+
path,
|
|
904
|
+
nodes: nodes ?? existing?.nodes ?? 0,
|
|
905
|
+
edges: edges ?? existing?.edges ?? 0,
|
|
906
|
+
communities: communities ?? existing?.communities ?? 0,
|
|
907
|
+
cached: cached ?? 0,
|
|
908
|
+
changed: changed ?? 0,
|
|
909
|
+
time_ms: time_ms ?? 0,
|
|
910
|
+
summary: summary || existing?.summary || '',
|
|
911
|
+
builtAt: new Date().toISOString(),
|
|
912
|
+
};
|
|
913
|
+
data.graph.build = record;
|
|
914
|
+
_dirtyProjects.add(projName);
|
|
915
|
+
markDirty();
|
|
916
|
+
return record;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
export function getGraph(path) {
|
|
920
|
+
init();
|
|
921
|
+
if (!path) return listGraphs();
|
|
922
|
+
const idx = loadProjectsIndex();
|
|
923
|
+
for (const proj of idx) {
|
|
924
|
+
if (normPath(proj.rootPath) === normPath(path)) {
|
|
925
|
+
return loadProjectData(proj.name).graph.build || null;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// fallback: scan all loaded data
|
|
929
|
+
for (const [, data] of _projectData.entries()) {
|
|
930
|
+
if (data.graph.build && normPath(data.graph.build.path) === normPath(path))
|
|
931
|
+
return data.graph.build;
|
|
932
|
+
}
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export function listGraphs() {
|
|
937
|
+
init();
|
|
938
|
+
const idx = loadProjectsIndex();
|
|
939
|
+
const results = [];
|
|
940
|
+
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
941
|
+
for (const name of seen) {
|
|
942
|
+
const build = loadProjectData(name).graph.build;
|
|
943
|
+
if (build) results.push(build);
|
|
944
|
+
}
|
|
945
|
+
return results;
|
|
946
|
+
}
|