@morningljn/mnemo 0.1.4 → 0.2.1
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/dist/dream.d.ts +2 -0
- package/dist/dream.js +20 -0
- package/dist/dream.js.map +1 -0
- package/dist/init.js +4 -24
- package/dist/init.js.map +1 -1
- package/dist/resources.d.ts +22 -8
- package/dist/resources.js +66 -20
- package/dist/resources.js.map +1 -1
- package/dist/retriever.js +42 -47
- package/dist/retriever.js.map +1 -1
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +21 -10
- package/dist/schema.js.map +1 -1
- package/dist/server.js +73 -6
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +59 -1
- package/dist/store.js +308 -10
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +30 -1
- package/docs/superpowers/plans/2026-05-16-memory-dreaming.md +626 -0
- package/docs/superpowers/plans/2026-05-16-memory-self-learning.md +932 -0
- package/openspec/changes/archive/2026-05-16-memory-dreaming/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-05-16-memory-dreaming/design.md +71 -0
- package/openspec/changes/archive/2026-05-16-memory-dreaming/proposal.md +32 -0
- package/openspec/changes/archive/2026-05-16-memory-dreaming/specs/compact-search/spec.md +16 -0
- package/openspec/changes/archive/2026-05-16-memory-dreaming/specs/dream-cycle/spec.md +38 -0
- package/openspec/changes/archive/2026-05-16-memory-dreaming/tasks.md +27 -0
- package/openspec/changes/memory-self-learning/.openspec.yaml +2 -0
- package/openspec/changes/memory-self-learning/design.md +174 -0
- package/openspec/changes/memory-self-learning/proposal.md +35 -0
- package/openspec/changes/memory-self-learning/specs/fact-retrieval/spec.md +35 -0
- package/openspec/changes/memory-self-learning/specs/fact-summary/spec.md +45 -0
- package/openspec/changes/memory-self-learning/specs/length-penalty/spec.md +27 -0
- package/openspec/changes/memory-self-learning/specs/retrieval-log/spec.md +41 -0
- package/openspec/changes/memory-self-learning/specs/self-learning/spec.md +68 -0
- package/openspec/changes/memory-self-learning/tasks.md +56 -0
- package/openspec/specs/compact-search/spec.md +16 -0
- package/openspec/specs/dream-cycle/spec.md +38 -0
- package/package.json +3 -2
- package/src/dream.ts +20 -0
- package/src/init.ts +4 -24
- package/src/resources.ts +77 -21
- package/src/retriever.ts +41 -49
- package/src/schema.ts +21 -10
- package/src/server.ts +81 -7
- package/src/store.ts +378 -11
- package/src/types.ts +28 -1
- package/tests/resource.test.ts +25 -23
- package/tests/retriever.test.ts +53 -0
- package/tests/store.test.ts +239 -0
package/src/server.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { MemoryStore } from './store.js'
|
|
|
9
9
|
import { FactRetriever } from './retriever.js'
|
|
10
10
|
import { ResourceManager } from './resources.js'
|
|
11
11
|
import { fullSecurityScan } from './security.js'
|
|
12
|
-
import type { FactStoreArgs, FactFeedbackArgs, FactCategory } from './types.js'
|
|
12
|
+
import type { FactStoreArgs, FactFeedbackArgs, FactCategory, ScoredFact, CompactFactResult } from './types.js'
|
|
13
13
|
|
|
14
14
|
const FACT_STORE_DESCRIPTION = `结构化事实记忆系统(SQLite+FTS5 索引)。支持读写。
|
|
15
15
|
|
|
@@ -27,8 +27,9 @@ const FACT_STORE_DESCRIPTION = `结构化事实记忆系统(SQLite+FTS5 索引
|
|
|
27
27
|
写入时先 search 检查是否已存在相似事实。identity/coding_style/tool_pref/workflow/general → 全局库,project → 项目库。`
|
|
28
28
|
|
|
29
29
|
const factStoreSchema = {
|
|
30
|
-
action: z.enum(['add', 'search', 'probe', 'related', 'reason', 'contradict', 'update', 'remove', 'list']),
|
|
30
|
+
action: z.enum(['add', 'search', 'probe', 'related', 'reason', 'contradict', 'update', 'remove', 'list', 'learn', 'audit', 'dream']),
|
|
31
31
|
content: z.union([z.string(), z.array(z.string())]).optional().describe("事实内容('add' 必需,支持批量)"),
|
|
32
|
+
summary: z.string().optional().describe('超长事实的摘要(检索用 summary 匹配)'),
|
|
32
33
|
query: z.string().optional().describe("搜索查询('search' 必需)"),
|
|
33
34
|
entity: z.string().optional().describe("实体名('probe'/'related' 使用)"),
|
|
34
35
|
entities: z.array(z.string()).optional().describe("实体列表('reason' 使用)"),
|
|
@@ -51,6 +52,16 @@ function resolveCategory(category?: string): FactCategory {
|
|
|
51
52
|
return valid.includes(category as FactCategory) ? (category as FactCategory) : 'general'
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
function toCompactResult(f: ScoredFact): CompactFactResult {
|
|
56
|
+
return {
|
|
57
|
+
factId: f.factId,
|
|
58
|
+
display: f.summary ?? (f.content.length > 100 ? f.content.slice(0, 100) + '...' : f.content),
|
|
59
|
+
category: f.category,
|
|
60
|
+
trustScore: Math.round(f.trustScore * 100) / 100,
|
|
61
|
+
score: Math.round(f.score * 1000) / 1000,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
const minTrust = 0.3
|
|
55
66
|
|
|
56
67
|
// -- Initialize store + retriever --
|
|
@@ -62,8 +73,38 @@ const retriever = new FactRetriever(store, { temporalDecayHalfLife: 30 })
|
|
|
62
73
|
store.decayTrustScores()
|
|
63
74
|
store.auditContradictions()
|
|
64
75
|
|
|
76
|
+
// Auto-learn on startup (non-blocking)
|
|
77
|
+
process.nextTick(() => {
|
|
78
|
+
try {
|
|
79
|
+
const result = store.runLearning()
|
|
80
|
+
if (result.demoted > 0 || result.aged > 0 || result.long_facts.length > 0) {
|
|
81
|
+
console.error(`[mnemo:auto-learn] promoted=${result.promoted} demoted=${result.demoted} aged=${result.aged} long_facts=${result.long_facts.length}`)
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.error('[mnemo:auto-learn] error:', err)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
65
88
|
// -- MCP Server --
|
|
66
|
-
|
|
89
|
+
// 动态生成 instructions:将 identity resource 中的角色设定作为 system prompt 指令注入
|
|
90
|
+
function buildInstructions(): string {
|
|
91
|
+
try {
|
|
92
|
+
const rm = new ResourceManager(store)
|
|
93
|
+
const result = rm.readCategory('identity')
|
|
94
|
+
const identityText = result.contents[0]?.text ?? ''
|
|
95
|
+
if (identityText.length > 10) {
|
|
96
|
+
return identityText
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// fallback:无 identity 数据时不注入
|
|
100
|
+
}
|
|
101
|
+
return ''
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const server = new McpServer(
|
|
105
|
+
{ name: 'mnemo-mcp', version: '0.1.0' },
|
|
106
|
+
{ instructions: buildInstructions() },
|
|
107
|
+
)
|
|
67
108
|
|
|
68
109
|
// -- MCP Resources: 会话预热注入 --
|
|
69
110
|
const resourceManager = new ResourceManager(store)
|
|
@@ -93,13 +134,22 @@ server.tool(
|
|
|
93
134
|
let warnings: string[] | undefined
|
|
94
135
|
const scan = fullSecurityScan(content)
|
|
95
136
|
if (scan.warnings.length > 0 || scan.hasPii) warnings = [...scan.warnings]
|
|
137
|
+
if (content.length > 500 && !a.summary) {
|
|
138
|
+
warnings = [...(warnings ?? []), 'content 超过 500 字,建议提供 summary 或拆分为多条 fact']
|
|
139
|
+
}
|
|
96
140
|
|
|
97
141
|
if (similar) {
|
|
98
142
|
store.updateFact(similar.factId, { content, tags: a.tags, trustDelta: 0.05 })
|
|
143
|
+
if (a.summary) {
|
|
144
|
+
store.connection.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run(a.summary, similar.factId)
|
|
145
|
+
}
|
|
99
146
|
const demoted = store.demoteContradictingFacts(similar.factId, content, category)
|
|
100
147
|
results.push({ fact_id: similar.factId, status: 'updated', reason: 'similar_fact_merged', ...(demoted > 0 ? { contradicted_demoted: demoted } : {}), ...(warnings ? { warnings } : {}) })
|
|
101
148
|
} else {
|
|
102
149
|
const factId = store.addFact(content, category, a.tags ?? '')
|
|
150
|
+
if (a.summary) {
|
|
151
|
+
store.connection.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run(a.summary, factId)
|
|
152
|
+
}
|
|
103
153
|
const demoted = store.demoteContradictingFacts(factId, content, category)
|
|
104
154
|
results.push({ fact_id: factId, status: 'added', category, ...(demoted > 0 ? { contradicted_demoted: demoted } : {}), ...(warnings ? { warnings } : {}) })
|
|
105
155
|
}
|
|
@@ -114,26 +164,30 @@ server.tool(
|
|
|
114
164
|
case 'search': {
|
|
115
165
|
if (!a.query) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Missing required argument: query' }) }] }
|
|
116
166
|
const results = retriever.search(a.query, { category: a.category ? category : undefined, minTrust: a.min_trust ?? minTrust, limit: a.limit ?? 10 })
|
|
117
|
-
|
|
167
|
+
const compact = results.map(toCompactResult)
|
|
168
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ results: compact, count: compact.length }) }] }
|
|
118
169
|
}
|
|
119
170
|
|
|
120
171
|
case 'probe': {
|
|
121
172
|
if (!a.entity) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Missing required argument: entity' }) }] }
|
|
122
173
|
const results = retriever.probe(a.entity, { minTrust: a.min_trust ?? minTrust, limit: a.limit ?? 10 })
|
|
123
|
-
|
|
174
|
+
const compact = results.map(toCompactResult)
|
|
175
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ results: compact, count: compact.length }) }] }
|
|
124
176
|
}
|
|
125
177
|
|
|
126
178
|
case 'related': {
|
|
127
179
|
if (!a.entity) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Missing required argument: entity' }) }] }
|
|
128
180
|
const results = retriever.related(a.entity, { minTrust: a.min_trust ?? minTrust, limit: a.limit ?? 10 })
|
|
129
|
-
|
|
181
|
+
const compact = results.map(toCompactResult)
|
|
182
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ results: compact, count: compact.length }) }] }
|
|
130
183
|
}
|
|
131
184
|
|
|
132
185
|
case 'reason': {
|
|
133
186
|
const entities = a.entities ?? []
|
|
134
187
|
if (entities.length === 0) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: "reason requires 'entities' list" }) }] }
|
|
135
188
|
const results = retriever.reason(entities, { minTrust: a.min_trust ?? minTrust, limit: a.limit ?? 10 })
|
|
136
|
-
|
|
189
|
+
const compact = results.map(toCompactResult)
|
|
190
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ results: compact, count: compact.length }) }] }
|
|
137
191
|
}
|
|
138
192
|
|
|
139
193
|
case 'contradict': {
|
|
@@ -144,6 +198,9 @@ server.tool(
|
|
|
144
198
|
case 'update': {
|
|
145
199
|
if (!a.fact_id) return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Missing required argument: fact_id' }) }] }
|
|
146
200
|
const updated = store.updateFact(a.fact_id as number, { content: a.content as string | undefined, tags: a.tags, category, trustDelta: a.trust_delta })
|
|
201
|
+
if (a.summary !== undefined) {
|
|
202
|
+
store.connection.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run(a.summary, a.fact_id as number)
|
|
203
|
+
}
|
|
147
204
|
retriever.getCache().clear()
|
|
148
205
|
resourceManager.invalidate()
|
|
149
206
|
return { content: [{ type: 'text' as const, text: JSON.stringify({ updated }) }] }
|
|
@@ -159,6 +216,23 @@ server.tool(
|
|
|
159
216
|
return { content: [{ type: 'text' as const, text: JSON.stringify(response) }] }
|
|
160
217
|
}
|
|
161
218
|
|
|
219
|
+
case 'learn': {
|
|
220
|
+
const result = store.runLearning()
|
|
221
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
case 'audit': {
|
|
225
|
+
const report = store.runAudit()
|
|
226
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(report) }] }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
case 'dream': {
|
|
230
|
+
const report = await store.runDream()
|
|
231
|
+
retriever.getCache().clear()
|
|
232
|
+
resourceManager.invalidate()
|
|
233
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(report) }] }
|
|
234
|
+
}
|
|
235
|
+
|
|
162
236
|
case 'list': {
|
|
163
237
|
const facts = store.listFacts(category, a.min_trust ?? 0.0, a.limit ?? 10)
|
|
164
238
|
return { content: [{ type: 'text' as const, text: JSON.stringify({ facts, count: facts.length }) }] }
|
package/src/store.ts
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
import Database from 'better-sqlite3'
|
|
13
13
|
import type { Statement } from 'better-sqlite3'
|
|
14
14
|
import { mkdirSync } from 'node:fs'
|
|
15
|
-
import { dirname } from 'node:path'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
16
|
import { SCHEMA } from './schema.js'
|
|
17
|
-
import type { Fact, FactCategory } from './types.js'
|
|
17
|
+
import type { Fact, FactCategory, DreamReport } from './types.js'
|
|
18
18
|
|
|
19
19
|
// 信任评分常量
|
|
20
20
|
const HELPFUL_DELTA = 0.05
|
|
@@ -63,9 +63,11 @@ interface FactRow {
|
|
|
63
63
|
category: string
|
|
64
64
|
tags: string
|
|
65
65
|
keywords: string
|
|
66
|
+
summary?: string | null
|
|
66
67
|
trust_score: number
|
|
67
68
|
retrieval_count: number
|
|
68
69
|
helpful_count: number
|
|
70
|
+
last_retrieved_at?: string | null
|
|
69
71
|
created_at: string
|
|
70
72
|
updated_at: string
|
|
71
73
|
}
|
|
@@ -110,10 +112,58 @@ export class MemoryStore {
|
|
|
110
112
|
|
|
111
113
|
/** 增量迁移:添加新列(已存在则跳过) */
|
|
112
114
|
private migrateSchema(): void {
|
|
115
|
+
const addColumn = (table: string, column: string, def: string): void => {
|
|
116
|
+
try {
|
|
117
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${def}`)
|
|
118
|
+
} catch { /* 列已存在 */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
113
121
|
// keywords 列(v2)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
122
|
+
addColumn('facts', 'keywords', "TEXT DEFAULT '[]'")
|
|
123
|
+
// summary 列
|
|
124
|
+
addColumn('facts', 'summary', 'TEXT DEFAULT NULL')
|
|
125
|
+
// last_retrieved_at 列
|
|
126
|
+
addColumn('facts', 'last_retrieved_at', 'TEXT DEFAULT NULL')
|
|
127
|
+
|
|
128
|
+
// FTS5 重建:检查 facts_fts 是否包含 summary 列
|
|
129
|
+
const ftsCols = this.db.pragma('table_info(facts_fts)') as Array<{ name: string }>
|
|
130
|
+
const hasSummary = ftsCols.some(c => c.name === 'summary')
|
|
131
|
+
if (!hasSummary) {
|
|
132
|
+
// DROP 旧 FTS5 表和触发器,重建含 summary 的新版本
|
|
133
|
+
this.db.exec(`
|
|
134
|
+
DROP TABLE IF EXISTS facts_fts;
|
|
135
|
+
DROP TRIGGER IF EXISTS facts_ai;
|
|
136
|
+
DROP TRIGGER IF EXISTS facts_ad;
|
|
137
|
+
DROP TRIGGER IF EXISTS facts_au;
|
|
138
|
+
`)
|
|
139
|
+
// 重建 FTS5 虚拟表(含 summary,使用 trigram tokenizer 支持中文子串)
|
|
140
|
+
this.db.exec(`
|
|
141
|
+
CREATE VIRTUAL TABLE facts_fts
|
|
142
|
+
USING fts5(content, tags, summary, content=facts, content_rowid=fact_id, tokenize='trigram');
|
|
143
|
+
`)
|
|
144
|
+
// 重填充
|
|
145
|
+
this.db.exec(`
|
|
146
|
+
INSERT INTO facts_fts(rowid, content, tags, summary)
|
|
147
|
+
SELECT fact_id, content, tags, COALESCE(summary, '') FROM facts;
|
|
148
|
+
`)
|
|
149
|
+
// 重建触发器
|
|
150
|
+
this.db.exec(`
|
|
151
|
+
CREATE TRIGGER facts_ai AFTER INSERT ON facts BEGIN
|
|
152
|
+
INSERT INTO facts_fts(rowid, content, tags, summary)
|
|
153
|
+
VALUES (new.fact_id, new.content, new.tags, COALESCE(new.summary, ''));
|
|
154
|
+
END;
|
|
155
|
+
CREATE TRIGGER facts_ad AFTER DELETE ON facts BEGIN
|
|
156
|
+
INSERT INTO facts_fts(facts_fts, rowid, content, tags, summary)
|
|
157
|
+
VALUES ('delete', old.fact_id, old.content, old.tags, COALESCE(old.summary, ''));
|
|
158
|
+
END;
|
|
159
|
+
CREATE TRIGGER facts_au AFTER UPDATE ON facts BEGIN
|
|
160
|
+
INSERT INTO facts_fts(facts_fts, rowid, content, tags, summary)
|
|
161
|
+
VALUES ('delete', old.fact_id, old.content, old.tags, COALESCE(old.summary, ''));
|
|
162
|
+
INSERT INTO facts_fts(rowid, content, tags, summary)
|
|
163
|
+
VALUES (new.fact_id, new.content, new.tags, COALESCE(new.summary, ''));
|
|
164
|
+
END;
|
|
165
|
+
`)
|
|
166
|
+
}
|
|
117
167
|
}
|
|
118
168
|
|
|
119
169
|
private prepareStatements(): void {
|
|
@@ -361,8 +411,8 @@ export class MemoryStore {
|
|
|
361
411
|
params.push(limit)
|
|
362
412
|
|
|
363
413
|
const sql = `
|
|
364
|
-
SELECT fact_id, content, category, tags, keywords, trust_score,
|
|
365
|
-
retrieval_count, helpful_count, created_at, updated_at
|
|
414
|
+
SELECT fact_id, content, category, tags, keywords, summary, trust_score,
|
|
415
|
+
retrieval_count, helpful_count, last_retrieved_at, created_at, updated_at
|
|
366
416
|
FROM facts
|
|
367
417
|
WHERE trust_score >= ?
|
|
368
418
|
${categoryClause}
|
|
@@ -410,8 +460,8 @@ export class MemoryStore {
|
|
|
410
460
|
params.push(limit)
|
|
411
461
|
|
|
412
462
|
const sql = `
|
|
413
|
-
SELECT f.fact_id, f.content, f.category, f.tags, f.keywords, f.trust_score,
|
|
414
|
-
f.retrieval_count, f.helpful_count, f.created_at, f.updated_at
|
|
463
|
+
SELECT f.fact_id, f.content, f.category, f.tags, f.keywords, f.summary, f.trust_score,
|
|
464
|
+
f.retrieval_count, f.helpful_count, f.last_retrieved_at, f.created_at, f.updated_at
|
|
415
465
|
FROM facts f
|
|
416
466
|
JOIN fact_entities fe ON f.fact_id = fe.fact_id
|
|
417
467
|
JOIN entities e ON fe.entity_id = e.entity_id
|
|
@@ -445,8 +495,8 @@ export class MemoryStore {
|
|
|
445
495
|
params.push(limit)
|
|
446
496
|
|
|
447
497
|
const sql = `
|
|
448
|
-
SELECT f.fact_id, f.content, f.category, f.tags, f.keywords, f.trust_score,
|
|
449
|
-
f.retrieval_count, f.helpful_count, f.created_at, f.updated_at
|
|
498
|
+
SELECT f.fact_id, f.content, f.category, f.tags, f.keywords, f.summary, f.trust_score,
|
|
499
|
+
f.retrieval_count, f.helpful_count, f.last_retrieved_at, f.created_at, f.updated_at
|
|
450
500
|
FROM facts f
|
|
451
501
|
WHERE f.fact_id IN (${intersects})
|
|
452
502
|
${categoryClause}
|
|
@@ -635,6 +685,321 @@ export class MemoryStore {
|
|
|
635
685
|
return row.count
|
|
636
686
|
}
|
|
637
687
|
|
|
688
|
+
/** 记录检索日志并更新 last_retrieved_at */
|
|
689
|
+
logRetrieval(query: string, results: Array<{ id: number; score: number }>): void {
|
|
690
|
+
const resultsJson = JSON.stringify(results)
|
|
691
|
+
this.db.prepare(
|
|
692
|
+
"INSERT INTO retrieval_log (query, results) VALUES (?, ?)"
|
|
693
|
+
).run(query, resultsJson)
|
|
694
|
+
|
|
695
|
+
// 更新返回 fact 的 last_retrieved_at
|
|
696
|
+
if (results.length > 0) {
|
|
697
|
+
const ids = results.map(r => r.id)
|
|
698
|
+
const placeholders = ids.map(() => '?').join(',')
|
|
699
|
+
this.db.prepare(
|
|
700
|
+
`UPDATE facts SET last_retrieved_at = datetime('now', 'localtime') WHERE fact_id IN (${placeholders})`
|
|
701
|
+
).run(...ids)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// 自动清理日志
|
|
705
|
+
this.pruneRetrievalLog(5000)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** 清理检索日志,保留最近 maxEntries 条 */
|
|
709
|
+
pruneRetrievalLog(maxEntries = 5000): void {
|
|
710
|
+
this.db.prepare(
|
|
711
|
+
`DELETE FROM retrieval_log WHERE id NOT IN (
|
|
712
|
+
SELECT id FROM retrieval_log ORDER BY id DESC LIMIT ?
|
|
713
|
+
)`
|
|
714
|
+
).run(maxEntries)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** 自学习:基于检索统计自动调整 trust_score */
|
|
718
|
+
runLearning(): {
|
|
719
|
+
promoted: number
|
|
720
|
+
demoted: number
|
|
721
|
+
aged: number
|
|
722
|
+
unchanged: number
|
|
723
|
+
long_facts: Array<{ id: number; content_length: number; penalty: number; has_summary: boolean }>
|
|
724
|
+
} {
|
|
725
|
+
const rows = this.db.prepare(
|
|
726
|
+
'SELECT fact_id, content, summary, retrieval_count, helpful_count, trust_score, last_retrieved_at FROM facts'
|
|
727
|
+
).all() as Array<{
|
|
728
|
+
fact_id: number; content: string; summary: string | null;
|
|
729
|
+
retrieval_count: number; helpful_count: number; trust_score: number; last_retrieved_at: string | null
|
|
730
|
+
}>
|
|
731
|
+
|
|
732
|
+
let promoted = 0
|
|
733
|
+
let demoted = 0
|
|
734
|
+
let aged = 0
|
|
735
|
+
let unchanged = 0
|
|
736
|
+
const longFacts: Array<{ id: number; content_length: number; penalty: number; has_summary: boolean }> = []
|
|
737
|
+
|
|
738
|
+
const now = Date.now()
|
|
739
|
+
|
|
740
|
+
for (const row of rows) {
|
|
741
|
+
let changed = false
|
|
742
|
+
const rate = row.retrieval_count > 0 ? row.helpful_count / row.retrieval_count : 0
|
|
743
|
+
|
|
744
|
+
// Rate-based adjustment (需要 30+ 次检索)
|
|
745
|
+
if (row.retrieval_count > 30) {
|
|
746
|
+
// 高频检索保护:检索 >100 次说明被持续需要,feedback 少不代表无用
|
|
747
|
+
const highFrequencyProtected = row.retrieval_count > 100
|
|
748
|
+
if (rate < 0.05 && !highFrequencyProtected) {
|
|
749
|
+
const newTrust = clampTrust(row.trust_score * 0.9)
|
|
750
|
+
this.db.prepare('UPDATE facts SET trust_score = ? WHERE fact_id = ?').run(newTrust, row.fact_id)
|
|
751
|
+
demoted++
|
|
752
|
+
changed = true
|
|
753
|
+
} else if (rate > 0.3) {
|
|
754
|
+
const newTrust = clampTrust(row.trust_score + 0.05)
|
|
755
|
+
this.db.prepare('UPDATE facts SET trust_score = ? WHERE fact_id = ?').run(newTrust, row.fact_id)
|
|
756
|
+
promoted++
|
|
757
|
+
changed = true
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Aging (60 天未检索)
|
|
762
|
+
if (row.last_retrieved_at) {
|
|
763
|
+
const lastRetrieved = new Date(row.last_retrieved_at + 'Z').getTime()
|
|
764
|
+
const daysSinceRetrieval = (now - lastRetrieved) / 86_400_000
|
|
765
|
+
if (daysSinceRetrieval > 60) {
|
|
766
|
+
const currentTrust = this.db.prepare('SELECT trust_score FROM facts WHERE fact_id = ?').get(row.fact_id) as any
|
|
767
|
+
const newTrust = clampTrust(currentTrust.trust_score * 0.95)
|
|
768
|
+
this.db.prepare('UPDATE facts SET trust_score = ? WHERE fact_id = ?').run(newTrust, row.fact_id)
|
|
769
|
+
aged++
|
|
770
|
+
changed = true
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
// last_retrieved_at 为 NULL = 新 fact,不老化
|
|
774
|
+
|
|
775
|
+
if (!changed) unchanged++
|
|
776
|
+
|
|
777
|
+
// Long facts report (content > 300 字无 summary)
|
|
778
|
+
const matchLength = row.summary ? row.summary.length : row.content.length
|
|
779
|
+
if (matchLength > 300) {
|
|
780
|
+
longFacts.push({
|
|
781
|
+
id: row.fact_id,
|
|
782
|
+
content_length: row.content.length,
|
|
783
|
+
penalty: Math.min(1.0, 300 / matchLength),
|
|
784
|
+
has_summary: !!row.summary,
|
|
785
|
+
})
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return { promoted, demoted, aged, unchanged, long_facts: longFacts }
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** 数据质量审计(只读,不修改数据) */
|
|
793
|
+
runAudit(): {
|
|
794
|
+
total_facts: number
|
|
795
|
+
long_without_summary: Array<{ id: number; content_length: number }>
|
|
796
|
+
low_helpful_rate: Array<{ id: number; rate: number; retrieval_count: number }>
|
|
797
|
+
aging_candidates: Array<{ id: number; last_retrieved_at: string | null }>
|
|
798
|
+
} {
|
|
799
|
+
const rows = this.db.prepare(
|
|
800
|
+
'SELECT fact_id, content, summary, retrieval_count, helpful_count, last_retrieved_at FROM facts'
|
|
801
|
+
).all() as Array<{
|
|
802
|
+
fact_id: number; content: string; summary: string | null;
|
|
803
|
+
retrieval_count: number; helpful_count: number; last_retrieved_at: string | null
|
|
804
|
+
}>
|
|
805
|
+
|
|
806
|
+
const longWithoutSummary: Array<{ id: number; content_length: number }> = []
|
|
807
|
+
const lowHelpfulRate: Array<{ id: number; rate: number; retrieval_count: number }> = []
|
|
808
|
+
const agingCandidates: Array<{ id: number; last_retrieved_at: string | null }> = []
|
|
809
|
+
|
|
810
|
+
const now = Date.now()
|
|
811
|
+
|
|
812
|
+
for (const row of rows) {
|
|
813
|
+
// 超 500 字无 summary
|
|
814
|
+
if (row.content.length > 500 && !row.summary) {
|
|
815
|
+
longWithoutSummary.push({ id: row.fact_id, content_length: row.content.length })
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// 低 helpful 率(>30 次检索,rate < 5%)
|
|
819
|
+
if (row.retrieval_count > 30) {
|
|
820
|
+
const rate = row.helpful_count / row.retrieval_count
|
|
821
|
+
if (rate < 0.05) {
|
|
822
|
+
lowHelpfulRate.push({ id: row.fact_id, rate: Math.round(rate * 1000) / 1000, retrieval_count: row.retrieval_count })
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// 老化候选(>60 天未检索)
|
|
827
|
+
if (row.last_retrieved_at) {
|
|
828
|
+
const lastRetrieved = new Date(row.last_retrieved_at + 'Z').getTime()
|
|
829
|
+
const daysSince = (now - lastRetrieved) / 86_400_000
|
|
830
|
+
if (daysSince > 60) {
|
|
831
|
+
agingCandidates.push({ id: row.fact_id, last_retrieved_at: row.last_retrieved_at })
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return {
|
|
837
|
+
total_facts: rows.length,
|
|
838
|
+
long_without_summary: longWithoutSummary,
|
|
839
|
+
low_helpful_rate: lowHelpfulRate,
|
|
840
|
+
aging_candidates: agingCandidates,
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Dream 前备份数据库(使用 better-sqlite3 内置备份,安全处理 WAL 模式) */
|
|
845
|
+
async backupDatabase(): Promise<string> {
|
|
846
|
+
const dbDir = dirname(this.db.name)
|
|
847
|
+
const backupDir = join(dbDir, 'backup')
|
|
848
|
+
mkdirSync(backupDir, { recursive: true })
|
|
849
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
850
|
+
const backupPath = join(backupDir, `dream-${timestamp}.db`)
|
|
851
|
+
await this.db.backup(backupPath)
|
|
852
|
+
return backupPath
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** 合并同 category 内 Jaccard > 0.6 的重叠 fact */
|
|
856
|
+
mergeOverlappingFacts(): { merged: number; details: Array<{ kept: number; removed: number; similarity: number }> } {
|
|
857
|
+
return this.db.transaction(() => {
|
|
858
|
+
const categories: FactCategory[] = ['identity', 'coding_style', 'tool_pref', 'workflow', 'general']
|
|
859
|
+
let merged = 0
|
|
860
|
+
const details: Array<{ kept: number; removed: number; similarity: number }> = []
|
|
861
|
+
|
|
862
|
+
for (const cat of categories) {
|
|
863
|
+
const rows = this.db.prepare(
|
|
864
|
+
'SELECT fact_id, content, retrieval_count FROM facts WHERE category = ? ORDER BY trust_score DESC'
|
|
865
|
+
).all(cat) as Array<{ fact_id: number; content: string; retrieval_count: number }>
|
|
866
|
+
|
|
867
|
+
const removed = new Set<number>()
|
|
868
|
+
|
|
869
|
+
for (let i = 0; i < rows.length; i++) {
|
|
870
|
+
if (removed.has(rows[i].fact_id)) continue
|
|
871
|
+
const tokensA = this.tokenizeForDedup(rows[i].content)
|
|
872
|
+
|
|
873
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
874
|
+
if (removed.has(rows[j].fact_id)) continue
|
|
875
|
+
const tokensB = this.tokenizeForDedup(rows[j].content)
|
|
876
|
+
const sim = this.jaccardSimilarity(tokensA, tokensB)
|
|
877
|
+
|
|
878
|
+
if (sim > 0.6) {
|
|
879
|
+
const aHighFreq = rows[i].retrieval_count > 100
|
|
880
|
+
const bHighFreq = rows[j].retrieval_count > 100
|
|
881
|
+
|
|
882
|
+
if (aHighFreq && bHighFreq) continue
|
|
883
|
+
|
|
884
|
+
let keptId: number, removedId: number
|
|
885
|
+
if (bHighFreq) {
|
|
886
|
+
keptId = rows[j].fact_id
|
|
887
|
+
removedId = rows[i].fact_id
|
|
888
|
+
} else {
|
|
889
|
+
keptId = rows[i].fact_id
|
|
890
|
+
removedId = rows[j].fact_id
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
this.removeFact(removedId)
|
|
894
|
+
removed.add(removedId)
|
|
895
|
+
details.push({ kept: keptId, removed: removedId, similarity: Math.round(sim * 100) / 100 })
|
|
896
|
+
merged++
|
|
897
|
+
// 外层 fact 被删除时停止内层循环,避免幽灵操作
|
|
898
|
+
if (removedId === rows[i].fact_id) break
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return { merged, details }
|
|
904
|
+
})()
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** 分类修正:按关键词规则表将误分类的 fact 挪到正确 category */
|
|
908
|
+
reclassifyFacts(): number {
|
|
909
|
+
return this.db.transaction(() => {
|
|
910
|
+
const rules: Array<{ keywords: string[]; target: FactCategory }> = [
|
|
911
|
+
{ keywords: ['角色设定', '暖暖', '身份', '编程女朋友', '暖宝宝'], target: 'identity' },
|
|
912
|
+
{ keywords: ['编码规范', '代码风格', 'pytest', '文件不超过', '方法不超过'], target: 'coding_style' },
|
|
913
|
+
{ keywords: ['工作流', 'OpenSpec', 'writing-plans', 'subagent'], target: 'workflow' },
|
|
914
|
+
{ keywords: ['偏好', 'VS Code', '编辑器', 'IDE', '快捷键'], target: 'tool_pref' },
|
|
915
|
+
]
|
|
916
|
+
|
|
917
|
+
const rows = this.db.prepare(
|
|
918
|
+
'SELECT fact_id, content, category FROM facts'
|
|
919
|
+
).all() as Array<{ fact_id: number; content: string; category: string }>
|
|
920
|
+
|
|
921
|
+
let reclassified = 0
|
|
922
|
+
for (const row of rows) {
|
|
923
|
+
for (const rule of rules) {
|
|
924
|
+
if (rule.target === row.category) continue
|
|
925
|
+
if (rule.keywords.some(kw => row.content.includes(kw))) {
|
|
926
|
+
this.db.prepare("UPDATE facts SET category = ?, updated_at = datetime('now', 'localtime') WHERE fact_id = ?").run(rule.target, row.fact_id)
|
|
927
|
+
reclassified++
|
|
928
|
+
break
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return reclassified
|
|
933
|
+
})()
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** 执行完整 dream cycle:备份 → 压缩 → 合并 → 重分类 → 报告 */
|
|
937
|
+
async runDream(options?: { skipBackup?: boolean }): Promise<DreamReport> {
|
|
938
|
+
if (!options?.skipBackup) {
|
|
939
|
+
await this.backupDatabase()
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const compressed = this.compressLongFacts()
|
|
943
|
+
const mergeResult = this.mergeOverlappingFacts()
|
|
944
|
+
const reclassified = this.reclassifyFacts()
|
|
945
|
+
|
|
946
|
+
const stats = this.db.prepare(`
|
|
947
|
+
SELECT COUNT(*) as total,
|
|
948
|
+
AVG(trust_score) as avg_trust,
|
|
949
|
+
AVG(length(content)) as avg_length
|
|
950
|
+
FROM facts
|
|
951
|
+
`).get() as { total: number; avg_trust: number; avg_length: number }
|
|
952
|
+
|
|
953
|
+
const categories: FactCategory[] = ['identity', 'coding_style', 'tool_pref', 'workflow', 'general']
|
|
954
|
+
const coverage: Record<string, number> = {}
|
|
955
|
+
for (const cat of categories) {
|
|
956
|
+
const row = this.db.prepare('SELECT COUNT(*) as c FROM facts WHERE category = ?').get(cat) as { c: number }
|
|
957
|
+
coverage[cat] = row.c
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
return {
|
|
961
|
+
merged: mergeResult.merged,
|
|
962
|
+
compressed,
|
|
963
|
+
reclassified,
|
|
964
|
+
deleted: mergeResult.merged,
|
|
965
|
+
mergeDetails: mergeResult.details,
|
|
966
|
+
health: {
|
|
967
|
+
total: stats.total,
|
|
968
|
+
avg_trust: Math.round((stats.avg_trust ?? 0) * 100) / 100,
|
|
969
|
+
avg_length: Math.round(stats.avg_length ?? 0),
|
|
970
|
+
coverage: coverage as Record<FactCategory, number>,
|
|
971
|
+
},
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** 压缩长 fact:content > 200 字且无 summary 的,自动提取前 2 句作为 summary */
|
|
976
|
+
compressLongFacts(): number {
|
|
977
|
+
const rows = this.db.prepare(
|
|
978
|
+
"SELECT fact_id, content FROM facts WHERE length(content) > 200 AND (summary IS NULL OR summary = '')"
|
|
979
|
+
).all() as Array<{ fact_id: number; content: string }>
|
|
980
|
+
|
|
981
|
+
let compressed = 0
|
|
982
|
+
for (const row of rows) {
|
|
983
|
+
const summary = this.extractSummary(row.content)
|
|
984
|
+
if (summary) {
|
|
985
|
+
this.db.prepare('UPDATE facts SET summary = ? WHERE fact_id = ?').run(summary, row.fact_id)
|
|
986
|
+
compressed++
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return compressed
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** 从 content 提取前 2 个完整句子(总长 ≤ 150 字) */
|
|
993
|
+
private extractSummary(content: string): string | null {
|
|
994
|
+
const sentences = content.split(/[。\n.]/).map(s => s.trim()).filter(s => s.length > 0)
|
|
995
|
+
if (sentences.length === 0) return null
|
|
996
|
+
let summary = sentences[0]
|
|
997
|
+
if (sentences.length > 1 && summary.length + sentences[1].length <= 148) {
|
|
998
|
+
summary += '。' + sentences[1]
|
|
999
|
+
}
|
|
1000
|
+
return summary.length <= 150 ? summary : summary.slice(0, 147) + '...'
|
|
1001
|
+
}
|
|
1002
|
+
|
|
638
1003
|
/** 获取数据库连接(供 FactRetriever 直接使用) */
|
|
639
1004
|
get connection(): Database.Database {
|
|
640
1005
|
return this.db
|
|
@@ -795,9 +1160,11 @@ export class MemoryStore {
|
|
|
795
1160
|
category: row.category as FactCategory,
|
|
796
1161
|
tags: row.tags,
|
|
797
1162
|
keywords: row.keywords,
|
|
1163
|
+
summary: (row as any).summary ?? null,
|
|
798
1164
|
trustScore: row.trust_score,
|
|
799
1165
|
retrievalCount: row.retrieval_count,
|
|
800
1166
|
helpfulCount: row.helpful_count,
|
|
1167
|
+
lastRetrievedAt: (row as any).last_retrieved_at ?? null,
|
|
801
1168
|
createdAt: row.created_at,
|
|
802
1169
|
updatedAt: row.updated_at,
|
|
803
1170
|
}
|
package/src/types.ts
CHANGED
|
@@ -8,9 +8,11 @@ export interface Fact {
|
|
|
8
8
|
category: FactCategory
|
|
9
9
|
tags: string
|
|
10
10
|
keywords: string
|
|
11
|
+
summary: string | null
|
|
11
12
|
trustScore: number
|
|
12
13
|
retrievalCount: number
|
|
13
14
|
helpfulCount: number
|
|
15
|
+
lastRetrievedAt: string | null
|
|
14
16
|
createdAt: string
|
|
15
17
|
updatedAt: string
|
|
16
18
|
}
|
|
@@ -53,7 +55,7 @@ export interface RetrieverOptions {
|
|
|
53
55
|
|
|
54
56
|
/** fact_store 工具调用参数 */
|
|
55
57
|
export interface FactStoreArgs {
|
|
56
|
-
action: 'add' | 'search' | 'probe' | 'related' | 'reason' | 'contradict' | 'update' | 'remove' | 'list'
|
|
58
|
+
action: 'add' | 'search' | 'probe' | 'related' | 'reason' | 'contradict' | 'update' | 'remove' | 'list' | 'learn' | 'audit' | 'dream'
|
|
57
59
|
content?: string | string[]
|
|
58
60
|
query?: string
|
|
59
61
|
entity?: string
|
|
@@ -61,6 +63,7 @@ export interface FactStoreArgs {
|
|
|
61
63
|
fact_id?: number | number[]
|
|
62
64
|
category?: string
|
|
63
65
|
tags?: string
|
|
66
|
+
summary?: string
|
|
64
67
|
trust_delta?: number
|
|
65
68
|
min_trust?: number
|
|
66
69
|
limit?: number
|
|
@@ -79,3 +82,27 @@ export interface SecurityScanResult {
|
|
|
79
82
|
hasPii: boolean
|
|
80
83
|
injectionAttempts: string[]
|
|
81
84
|
}
|
|
85
|
+
|
|
86
|
+
/** Dream 整理报告 */
|
|
87
|
+
export interface DreamReport {
|
|
88
|
+
merged: number
|
|
89
|
+
compressed: number
|
|
90
|
+
reclassified: number
|
|
91
|
+
deleted: number
|
|
92
|
+
mergeDetails: Array<{ kept: number; removed: number; similarity: number }>
|
|
93
|
+
health: {
|
|
94
|
+
total: number
|
|
95
|
+
avg_trust: number
|
|
96
|
+
avg_length: number
|
|
97
|
+
coverage: Record<FactCategory, number>
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 精简搜索结果 */
|
|
102
|
+
export interface CompactFactResult {
|
|
103
|
+
factId: number
|
|
104
|
+
display: string
|
|
105
|
+
category: FactCategory
|
|
106
|
+
trustScore: number
|
|
107
|
+
score: number
|
|
108
|
+
}
|