mio-agent-runtime 0.5.22 → 0.6.0

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.
@@ -0,0 +1,69 @@
1
+ 'use strict'
2
+
3
+ // Shared LLM client: the single implementation of chatJson, consumed by the MCP
4
+ // server (creativity engine + insight store) and by the CLI
5
+ // (`mio creativity generate` / `ferment`). Extracted from
6
+ // mio-intelligence-mcp/index.js so both entry points call the same model with
7
+ // the same configuration, and so "is an LLM configured?" has one answer.
8
+ //
9
+ // Configuration is entirely environment-based:
10
+ // LLM_API_URL OpenAI-compatible endpoint (default: opencode zen)
11
+ // LLM_KEY bearer token; empty means unauthenticated (common for a
12
+ // local Ollama / llama.cpp server)
13
+ // LLM_CHAT_MODEL model id, falls back to LLM_MODEL
14
+
15
+ const DEFAULT_API_URL = 'https://opencode.ai/zen/go/v1/chat/completions'
16
+ const DEFAULT_MODEL = 'deepseek-v4-flash'
17
+
18
+ function llmConfig() {
19
+ return {
20
+ apiUrl: process.env.LLM_API_URL || DEFAULT_API_URL,
21
+ apiKey: process.env.LLM_KEY || '',
22
+ model: process.env.LLM_CHAT_MODEL || process.env.LLM_MODEL || DEFAULT_MODEL,
23
+ }
24
+ }
25
+
26
+ // True when the user has pointed us somewhere other than the default hosted
27
+ // endpoint, or supplied a key. Used to warn (not block) before spending a call.
28
+ function isLlmConfigured() {
29
+ return Boolean(process.env.LLM_API_URL || process.env.LLM_KEY)
30
+ }
31
+
32
+ async function chatJson(userText, opts = {}) {
33
+ const { apiUrl, apiKey, model } = llmConfig()
34
+ const system = opts.system || 'You are a creative AI assistant. Output JSON.'
35
+ const temperature = opts.temperature ?? 0.3
36
+
37
+ try {
38
+ const resp = await fetch(apiUrl, {
39
+ method: 'POST',
40
+ headers: {
41
+ 'Content-Type': 'application/json',
42
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
43
+ },
44
+ body: JSON.stringify({
45
+ model,
46
+ temperature,
47
+ messages: [
48
+ { role: 'system', content: system },
49
+ { role: 'user', content: userText },
50
+ ],
51
+ response_format: { type: 'json_object' },
52
+ }),
53
+ signal: AbortSignal.timeout(opts.timeoutMs || 30000),
54
+ })
55
+
56
+ if (!resp.ok) return { error: `HTTP ${resp.status}` }
57
+ const json = await resp.json()
58
+ const text = json.choices?.[0]?.message?.content || ''
59
+ try {
60
+ return { data: JSON.parse(text) }
61
+ } catch {
62
+ return { data: text }
63
+ }
64
+ } catch (err) {
65
+ return { error: String(err) }
66
+ }
67
+ }
68
+
69
+ module.exports = { chatJson, llmConfig, isLlmConfigured }
@@ -34,6 +34,84 @@ function appendJsonl(file, value) {
34
34
  fs.appendFileSync(file, `${JSON.stringify(value)}\n`, 'utf8')
35
35
  }
36
36
 
37
+ function writeJsonl(file, values) {
38
+ fs.mkdirSync(path.dirname(file), { recursive: true })
39
+ fs.writeFileSync(
40
+ file,
41
+ values.length > 0 ? values.map((value) => JSON.stringify(value)).join('\n') + '\n' : '',
42
+ 'utf8'
43
+ )
44
+ }
45
+
46
+ // --- Duplicate / quality analysis (mio.memory.analyze) ---------------------
47
+ // Threshold is intentionally conservative: two records are "duplicates" only
48
+ // when their token sets are nearly identical, because a false positive here
49
+ // suggests archiving a record the user still needs.
50
+ const MEMORY_DUPLICATE_SIMILARITY = 0.75
51
+ const MEMORY_SHORT_CONTENT_MIN = 20
52
+ // Pairwise Jaccard is O(n^2); beyond this many active records we skip the
53
+ // duplicate scan and report `duplicatesSkipped` instead of hanging.
54
+ const MAX_PAIRWISE_MEMORY = 1500
55
+
56
+ function contentTokens(value) {
57
+ const text = String(value || '').toLowerCase()
58
+ const latin = text.match(/[a-z0-9]+/g) || []
59
+ const chars = text.match(/[\u4e00-\u9fff]/g) || []
60
+ const bigrams = []
61
+ for (let i = 0; i < chars.length - 1; i++) {
62
+ bigrams.push(`${chars[i]}${chars[i + 1]}`)
63
+ }
64
+ return [...latin, ...bigrams]
65
+ }
66
+
67
+ function jaccardSimilarity(a, b) {
68
+ const setA = new Set(contentTokens(a))
69
+ const setB = new Set(contentTokens(b))
70
+ if (setA.size === 0 || setB.size === 0) return 0
71
+ let intersection = 0
72
+ for (const token of setA) {
73
+ if (setB.has(token)) intersection += 1
74
+ }
75
+ const union = setA.size + setB.size - intersection
76
+ return union === 0 ? 0 : intersection / union
77
+ }
78
+
79
+ // Union-find over pairs above the similarity threshold, so "A~B, B~C" collapses
80
+ // into one group of three rather than three overlapping pairs.
81
+ function findDuplicateGroups(records, limit) {
82
+ const parent = new Map()
83
+ for (const record of records) parent.set(record.id, record.id)
84
+ const find = (id) => {
85
+ while (parent.get(id) !== id) {
86
+ parent.set(id, parent.get(parent.get(id)))
87
+ id = parent.get(id)
88
+ }
89
+ return id
90
+ }
91
+ const union = (a, b) => {
92
+ const ra = find(a)
93
+ const rb = find(b)
94
+ if (ra !== rb) parent.set(ra, rb)
95
+ }
96
+ for (let i = 0; i < records.length; i++) {
97
+ for (let j = i + 1; j < records.length; j++) {
98
+ if (jaccardSimilarity(records[i].content, records[j].content) >= MEMORY_DUPLICATE_SIMILARITY) {
99
+ union(records[i].id, records[j].id)
100
+ }
101
+ }
102
+ }
103
+ const byRoot = new Map()
104
+ for (const record of records) {
105
+ const root = find(record.id)
106
+ if (!byRoot.has(root)) byRoot.set(root, [])
107
+ byRoot.get(root).push(record)
108
+ }
109
+ return Array.from(byRoot.values())
110
+ .filter((group) => group.length >= 2)
111
+ .sort((a, b) => b.length - a.length)
112
+ .slice(0, limit)
113
+ }
114
+
37
115
  function normalizeTags(tags) {
38
116
  if (Array.isArray(tags)) return tags.map((tag) => String(tag).trim()).filter(Boolean)
39
117
  if (typeof tags === 'string') {
@@ -159,12 +237,21 @@ function createMemoryStore(options = {}) {
159
237
  // Hook that persists a recent-query entry (phase 0 auto-claim input).
160
238
  // Optional: the CLI passes null because no task_outcome can follow a
161
239
  // terminal command, so recording a query there would only add noise.
162
- const recordQuery = options.recordQuery || null
240
+ //
241
+ // Prefer passing `queryLog` (a createQueryLog instance) so memory and task
242
+ // stores share one implementation of queries.jsonl; `recordQuery` remains
243
+ // supported as a plain callback for callers that do not have a queryLog.
244
+ const queryLog = options.queryLog || null
245
+ const recordQuery =
246
+ options.recordQuery || (queryLog ? (entry) => queryLog.record(entry) : null)
247
+ const reuseMatchWindowMs =
248
+ options.reuseMatchWindowMs || (queryLog ? queryLog.windowMs : 60 * 60 * 1000)
163
249
  const verifiedFilter = options.verifiedFilter || defaultVerifiedFilter
164
250
 
165
251
  const memoryPath = path.join(dataDir, 'memory.jsonl')
166
252
  const experienceReusePath = path.join(dataDir, 'experience_reuse.jsonl')
167
253
  const tracePath = path.join(dataDir, 'traces.jsonl')
254
+ const forgetAuditPath = path.join(dataDir, 'memory-forget-audit.jsonl')
168
255
 
169
256
  function loadEvidenceWeights() {
170
257
  const weights = new Map()
@@ -234,7 +321,7 @@ function createMemoryStore(options = {}) {
234
321
  resultIds: results.map((record) => record.id).filter(Boolean),
235
322
  resultSources: results.map((record) => record.source || null),
236
323
  timestamp,
237
- expiresAt: timestamp + (options.reuseMatchWindowMs || 60 * 60 * 1000),
324
+ expiresAt: timestamp + reuseMatchWindowMs,
238
325
  })
239
326
  }
240
327
  return {
@@ -349,18 +436,431 @@ function createMemoryStore(options = {}) {
349
436
  return record
350
437
  }
351
438
 
439
+ // Quality report over the active (non-archived) records for a project:
440
+ // kind histogram, low-quality records, and near-duplicate groups. This is the
441
+ // diagnostic half of memory hygiene — `archiveMemory` is the fix.
442
+ function analyzeMemory(args = {}) {
443
+ const project = args.project || projectName()
444
+ const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 20)
445
+ const records = readJsonl(memoryPath).filter(
446
+ (record) => !project || record.project === project || !record.project
447
+ )
448
+ const archived = records.filter((record) => record.archived === true).length
449
+ const activeRecords = records.filter((record) => record.archived !== true)
450
+ const total = activeRecords.length
451
+ const layerCounts = { project: 0, global: 0 }
452
+ for (const record of activeRecords) {
453
+ if (record.scope === 'global' || !record.project) layerCounts.global += 1
454
+ else layerCounts.project += 1
455
+ }
456
+
457
+ const byKind = {}
458
+ const lowQuality = []
459
+ for (const record of activeRecords) {
460
+ const kind = String(record.kind || '').trim() || 'unknown'
461
+ byKind[kind] = (byKind[kind] || 0) + 1
462
+ const content = String(record.content || '').trim()
463
+ const issues = []
464
+ if (!content) issues.push('missing content')
465
+ else if (content.length < MEMORY_SHORT_CONTENT_MIN) issues.push('content too short')
466
+ if (!record.kind || !String(record.kind).trim()) issues.push('missing kind')
467
+ if (issues.length > 0) {
468
+ lowQuality.push({
469
+ id: record.id,
470
+ timestamp: record.timestamp,
471
+ kind: record.kind || null,
472
+ content: content.slice(0, 200) || null,
473
+ issues,
474
+ })
475
+ }
476
+ }
477
+
478
+ let duplicateGroups = []
479
+ let duplicatesSkipped = false
480
+ if (activeRecords.length > MAX_PAIRWISE_MEMORY) {
481
+ duplicatesSkipped = true
482
+ } else {
483
+ duplicateGroups = findDuplicateGroups(activeRecords, limit).map((group) => ({
484
+ size: group.length,
485
+ records: group.map((record) => ({
486
+ id: record.id,
487
+ timestamp: record.timestamp,
488
+ kind: record.kind || null,
489
+ content: String(record.content || '').slice(0, 200),
490
+ })),
491
+ }))
492
+ }
493
+
494
+ const issues = {
495
+ lowQuality: lowQuality.length,
496
+ duplicateGroups: duplicateGroups.length,
497
+ duplicatesSkipped,
498
+ }
499
+
500
+ const suggestions = []
501
+ if (duplicateGroups.length > 0) {
502
+ suggestions.push(
503
+ `Found ${duplicateGroups.length} duplicate group(s); consider archiving or merging duplicates to keep recall precise.`
504
+ )
505
+ }
506
+ if (lowQuality.length > 0) {
507
+ suggestions.push(
508
+ `${lowQuality.length} low-quality record(s) (missing kind/content or too short); consider fixing or removing them.`
509
+ )
510
+ }
511
+ if (total === 0) {
512
+ suggestions.push('No memory records for this project yet.')
513
+ } else if (duplicateGroups.length === 0 && lowQuality.length === 0) {
514
+ suggestions.push('Memory looks healthy; no dedup or quality fixes needed.')
515
+ }
516
+
517
+ return {
518
+ project: project || null,
519
+ total,
520
+ archived,
521
+ layers: layerCounts,
522
+ byKind,
523
+ issues,
524
+ duplicates: duplicateGroups,
525
+ lowQuality: lowQuality.slice(0, limit),
526
+ suggestions,
527
+ }
528
+ }
529
+
530
+ function migrateMemory(args = {}) {
531
+ const idsInput = Array.isArray(args.ids) ? args.ids : []
532
+ const ids = idsInput.map((value) => String(value).trim()).filter(Boolean)
533
+ if (ids.length === 0) throw new Error('memory.migrate requires ids (array of memory record ids)')
534
+ const targetScope = normalizeScope(args.scope)
535
+ if (targetScope === 'all') throw new Error('memory.migrate scope must be project or global')
536
+ const project = args.project || projectName()
537
+ const reason = args.reason ? String(args.reason).trim().slice(0, 200) : null
538
+
539
+ const records = readJsonl(memoryPath)
540
+ const idSet = new Set(ids)
541
+ const updated = []
542
+ const unchanged = []
543
+ const notFound = [...ids]
544
+ for (const record of records) {
545
+ if (!idSet.has(record.id)) continue
546
+ const fromScope = record.scope || 'project'
547
+ const fromProject = record.project || null
548
+ if (fromScope === targetScope) {
549
+ unchanged.push(record.id)
550
+ } else {
551
+ record.scope = targetScope
552
+ record.project = targetScope === 'global' ? null : project
553
+ record.migratedAt = new Date().toISOString()
554
+ record.migratedFrom = fromScope === 'global' ? 'global' : fromProject || 'project'
555
+ if (reason) record.migrateReason = reason
556
+ updated.push(record.id)
557
+ }
558
+ const index = notFound.indexOf(record.id)
559
+ if (index >= 0) notFound.splice(index, 1)
560
+ }
561
+ writeJsonl(memoryPath, records)
562
+
563
+ return {
564
+ project: project || null,
565
+ scope: targetScope,
566
+ updated,
567
+ updatedCount: updated.length,
568
+ unchanged,
569
+ notFound,
570
+ }
571
+ }
572
+
573
+ // Merge near-duplicate records into one survivor.
574
+ //
575
+ // Safety stance (measured, not assumed): of 22 duplicate groups in a real
576
+ // 860-record store, 19 held byte-identical content and 3 held *divergent*
577
+ // content. Concatenating divergent bodies is actively harmful -- a real
578
+ // example produced a record carrying both `Token 来源` and `凭证来源` for the
579
+ // same field plus a duplicated header. So by default this only merges groups
580
+ // whose members are byte-identical, and reports the rest as `divergent`
581
+ // for the caller to inspect. Nothing is ever silently concatenated.
582
+ //
583
+ // Merging is archive-based, so it is reversible with archiveMemory({restore}).
584
+ function mergeMemory(args = {}) {
585
+ const idsInput = Array.isArray(args.ids) ? args.ids : []
586
+ const ids = idsInput.map((value) => String(value).trim()).filter(Boolean)
587
+ if (ids.length < 2) throw new Error('memory.merge requires at least 2 ids')
588
+ const project = args.project || projectName()
589
+ const reason = args.reason ? String(args.reason).trim().slice(0, 200) : null
590
+ // keep: explicit survivor id. When absent, the newest record wins.
591
+ const keep = args.keep ? String(args.keep).trim() : null
592
+ // allowDivergent: opt-in escape hatch. Still refuses if no keep is named,
593
+ // because "which divergence wins" is then undefined.
594
+ const allowDivergent = args.allowDivergent === true || args.allowDivergent === 'true'
595
+
596
+ const records = readJsonl(memoryPath)
597
+ const idSet = new Set(ids)
598
+ // The project filter must be applied once, up front. Applying it only in the
599
+ // archive loop let a mismatched project still rewrite the survivor's
600
+ // `supersedes` list, recording an absorption of records that were never
601
+ // archived -- audit metadata pointing at nothing.
602
+ const allMembers = records.filter((record) => idSet.has(record.id))
603
+ const members = project
604
+ ? allMembers.filter((record) => !record.project || record.project === project)
605
+ : allMembers
606
+ const notFound = ids.filter((id) => !allMembers.some((record) => record.id === id))
607
+ const outOfScope = allMembers
608
+ .filter((record) => !members.some((member) => member.id === record.id))
609
+ .map((record) => record.id)
610
+
611
+ if (members.length < 2) {
612
+ return {
613
+ project: project || null,
614
+ merged: false,
615
+ reason:
616
+ outOfScope.length > 0
617
+ ? 'fewer than 2 ids are in this project; records belong to another project'
618
+ : 'fewer than 2 ids resolved to records',
619
+ survivor: null,
620
+ archived: [],
621
+ archivedCount: 0,
622
+ skipped: [],
623
+ divergent: [],
624
+ outOfScope,
625
+ notFound,
626
+ }
627
+ }
628
+
629
+ const contents = new Set(members.map((record) => String(record.content || '')))
630
+ const divergentFrom = contents.size > 1
631
+
632
+ if (divergentFrom && !allowDivergent) {
633
+ return {
634
+ project: project || null,
635
+ merged: false,
636
+ reason: 'content differs across records; refusing to concatenate',
637
+ survivor: null,
638
+ archived: [],
639
+ archivedCount: 0,
640
+ skipped: [],
641
+ divergent: members.map((record) => ({
642
+ id: record.id,
643
+ timestamp: record.timestamp || null,
644
+ contentLength: String(record.content || '').length,
645
+ content: String(record.content || '').slice(0, 200),
646
+ })),
647
+ outOfScope,
648
+ notFound,
649
+ hint: 'Re-run with --keep <id> --allow-divergent to choose the surviving body explicitly.',
650
+ }
651
+ }
652
+
653
+ if (divergentFrom && allowDivergent && !keep) {
654
+ throw new Error('memory.merge with divergent content requires keep (the id whose body survives)')
655
+ }
656
+
657
+ // Survivor selection: explicit keep, else newest by timestamp.
658
+ let survivor = null
659
+ if (keep) {
660
+ survivor = members.find((record) => record.id === keep)
661
+ if (!survivor) throw new Error(`memory.merge keep id not in the merge set: ${keep}`)
662
+ } else {
663
+ survivor = members.reduce((best, record) => {
664
+ const a = Date.parse(record.timestamp || 0) || 0
665
+ const b = Date.parse(best.timestamp || 0) || 0
666
+ return a > b ? record : best
667
+ })
668
+ }
669
+
670
+ const superseded = members
671
+ .filter((record) => record.id !== survivor.id)
672
+ .map((record) => record.id)
673
+ const mergedAt = new Date().toISOString()
674
+
675
+ // Union tags so a merge never loses retrieval keywords.
676
+ const tagSet = new Set(normalizeTags(survivor.tags))
677
+ for (const record of members) {
678
+ for (const tag of normalizeTags(record.tags)) tagSet.add(tag)
679
+ }
680
+ survivor.tags = Array.from(tagSet)
681
+
682
+ // Audit trail: which records this one absorbed, and when.
683
+ const priorSuperseded = Array.isArray(survivor.supersedes) ? survivor.supersedes : []
684
+ survivor.supersedes = Array.from(new Set([...priorSuperseded, ...superseded]))
685
+ survivor.mergedAt = mergedAt
686
+ survivor.mergedCount = survivor.supersedes.length
687
+ if (divergentFrom) survivor.mergeDivergent = true
688
+ if (reason) survivor.mergeReason = reason
689
+
690
+ const archived = []
691
+ const skipped = []
692
+ const memberIds = new Set(members.map((member) => member.id))
693
+ for (const record of records) {
694
+ if (record.id === survivor.id) continue
695
+ // members already passed the project filter, so no re-check is needed here.
696
+ if (!memberIds.has(record.id)) continue
697
+ if (record.archived === true) {
698
+ skipped.push(record.id)
699
+ continue
700
+ }
701
+ record.archived = true
702
+ record.archivedAt = mergedAt
703
+ record.archiveReason = `merged-into:${survivor.id}`
704
+ record.mergedInto = survivor.id
705
+ archived.push(record.id)
706
+ }
707
+ writeJsonl(memoryPath, records)
708
+
709
+ return {
710
+ project: project || null,
711
+ merged: true,
712
+ divergentContent: divergentFrom,
713
+ survivor: {
714
+ id: survivor.id,
715
+ timestamp: survivor.timestamp || null,
716
+ contentLength: String(survivor.content || '').length,
717
+ supersedes: survivor.supersedes,
718
+ tags: survivor.tags,
719
+ },
720
+ archived,
721
+ archivedCount: archived.length,
722
+ skipped,
723
+ divergent: [],
724
+ outOfScope,
725
+ notFound,
726
+ }
727
+ }
728
+
729
+ // Soft delete: archived records stay on disk but drop out of memory.query and
730
+ // memory.analyze. Reversible via `restore: true`.
731
+ function archiveMemory(args = {}) {
732
+ const idsInput = Array.isArray(args.ids) ? args.ids : []
733
+ const ids = idsInput.map((value) => String(value).trim()).filter(Boolean)
734
+ if (ids.length === 0) throw new Error('memory.archive requires ids (array of memory record ids)')
735
+ const project = args.project || projectName()
736
+ const restore = args.restore === true || args.restore === 'true'
737
+ const reason = args.reason ? String(args.reason).trim().slice(0, 200) : null
738
+
739
+ const records = readJsonl(memoryPath)
740
+ const idSet = new Set(ids)
741
+ const archived = []
742
+ // Ids that exist but are already in the requested state. Reported separately
743
+ // from `notFound` so callers can tell "already done" from "bad id".
744
+ const skipped = []
745
+ const notFound = [...ids]
746
+ for (const record of records) {
747
+ if (!idSet.has(record.id)) continue
748
+ if (project && record.project && record.project !== project) continue
749
+ if (restore) {
750
+ if (record.archived === true) {
751
+ delete record.archived
752
+ delete record.archivedAt
753
+ delete record.archiveReason
754
+ archived.push(record.id)
755
+ } else {
756
+ skipped.push(record.id)
757
+ }
758
+ } else if (record.archived !== true) {
759
+ record.archived = true
760
+ record.archivedAt = new Date().toISOString()
761
+ if (reason) record.archiveReason = reason
762
+ archived.push(record.id)
763
+ } else {
764
+ skipped.push(record.id)
765
+ }
766
+ const index = notFound.indexOf(record.id)
767
+ if (index >= 0) notFound.splice(index, 1)
768
+ }
769
+ writeJsonl(memoryPath, records)
770
+
771
+ return {
772
+ project: project || null,
773
+ restored: restore,
774
+ archived,
775
+ archivedCount: archived.length,
776
+ skipped,
777
+ notFound,
778
+ }
779
+ }
780
+
781
+ // forget is the only hard delete in the memory surface: archive is reversible
782
+ // (archived: true), merge is archive-based, and prune --memory is age-based.
783
+ // Because the record is gone afterwards, an audit entry is written FIRST and
784
+ // the delete only proceeds once that succeeded -- otherwise a "forgotten"
785
+ // record would leave no trace of what it said.
786
+ //
787
+ // The audit keeps an excerpt rather than the full body: enough to answer
788
+ // "what was removed" later, without quietly retaining everything the user
789
+ // asked to delete.
790
+ function forgetMemory(args = {}) {
791
+ const idsInput = Array.isArray(args.ids) ? args.ids : []
792
+ const ids = idsInput.map((value) => String(value).trim()).filter(Boolean)
793
+ if (ids.length === 0) throw new Error('memory.forget requires ids (array of memory record ids)')
794
+ const project = args.project || projectName()
795
+ const reason = args.reason ? String(args.reason).trim().slice(0, 200) : null
796
+ const by = args.by ? String(args.by).trim().slice(0, 80) : null
797
+
798
+ const records = readJsonl(memoryPath)
799
+ const idSet = new Set(ids)
800
+ const forgotten = []
801
+ const notFound = [...ids]
802
+ const kept = []
803
+ for (const record of records) {
804
+ if (!idSet.has(record.id)) {
805
+ kept.push(record)
806
+ continue
807
+ }
808
+ // Out-of-project ids stay on file and are reported as notFound, matching
809
+ // how archiveMemory scopes by project.
810
+ if (project && record.project && record.project !== project) {
811
+ kept.push(record)
812
+ continue
813
+ }
814
+ const index = notFound.indexOf(record.id)
815
+ if (index >= 0) notFound.splice(index, 1)
816
+ const content = String(record.content || '')
817
+ appendJsonl(forgetAuditPath, {
818
+ id: createId('forget'),
819
+ forgottenId: record.id,
820
+ project: record.project || null,
821
+ timestamp: new Date().toISOString(),
822
+ reason,
823
+ by,
824
+ kind: record.kind || null,
825
+ contentLength: content.length,
826
+ excerpt: content.replace(/\s+/g, ' ').slice(0, 120),
827
+ })
828
+ forgotten.push(record.id)
829
+ }
830
+
831
+ if (forgotten.length > 0) writeJsonl(memoryPath, kept)
832
+
833
+ return {
834
+ project: project || null,
835
+ forgotten,
836
+ forgottenCount: forgotten.length,
837
+ notFound,
838
+ auditPath: forgetAuditPath,
839
+ }
840
+ }
841
+
352
842
  return {
353
843
  memoryPath,
354
844
  experienceReusePath,
355
845
  tracePath,
846
+ forgetAuditPath,
356
847
  queryMemory,
357
848
  recordMemory,
358
849
  queryTraces,
850
+ analyzeMemory,
851
+ archiveMemory,
852
+ forgetMemory,
853
+ mergeMemory,
854
+ migrateMemory,
359
855
  loadEvidenceWeights,
360
856
  // pure helpers, exported for tests and other consumers
361
857
  createId,
362
858
  readJsonl,
363
859
  appendJsonl,
860
+ writeJsonl,
861
+ contentTokens,
862
+ jaccardSimilarity,
863
+ findDuplicateGroups,
364
864
  normalizeTags,
365
865
  tokenize,
366
866
  normalizeScope,
@@ -373,4 +873,6 @@ function createMemoryStore(options = {}) {
373
873
  }
374
874
  }
375
875
 
376
- module.exports = { createMemoryStore }
876
+ // Module-level exports let sibling stores (e.g. experience-store.js) reuse the
877
+ // same JSONL primitives instead of duplicating them and drifting apart.
878
+ module.exports = { createMemoryStore, createId, readJsonl, appendJsonl, writeJsonl }