mio-agent-runtime 0.5.23 → 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.
- package/README.md +485 -85
- package/bin/mio.js +1882 -78
- package/observe/observer.js +73 -15
- package/package.json +45 -45
- package/scripts/check-syntax.cjs +72 -0
- package/server/agent-store.js +140 -0
- package/server/evolution-report.js +311 -0
- package/server/experience-store.js +214 -0
- package/server/insight-store.js +118 -0
- package/server/llm-client.js +69 -0
- package/server/memory-store.js +505 -3
- package/server/mio-intelligence-mcp/index.js +126 -1521
- package/server/observer-store.js +219 -0
- package/server/policy-store.js +263 -0
- package/server/query-log.js +66 -0
- package/server/subscription-store.js +213 -0
- package/server/task-store.js +385 -0
|
@@ -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 }
|
package/server/memory-store.js
CHANGED
|
@@ -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
|
-
|
|
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 +
|
|
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
|
-
|
|
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 }
|