ljsysfurry commited on
Commit
3bef79f
·
verified ·
1 Parent(s): dae80a3

sync cleaned docs: dsh-plugin/memory-director/src/index.ts

Browse files
dsh-plugin/memory-director/src/index.ts ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * dsh-memory-director — MemoryDirector plugin for DeepSeek Harness.
3
+ *
4
+ * Official Harness compacts history via summarization, but has NO concept of
5
+ * "which facts are worth remembering across sessions". This plugin adds the
6
+ * AgentFrame MemoryDirector: after each turn, an LLM decides what to
7
+ * remember / forget; before each step, relevant memories are injected into
8
+ * the model context.
9
+ *
10
+ * @module @agentframe/dsh-memory-director
11
+ */
12
+
13
+ import { Context } from '@deepseek-ai/cordis'
14
+ import z from '@deepseek-ai/schemastery'
15
+ import type { Agent } from '@deepseek-ai/dsh-agent'
16
+
17
+ /** One persisted memory entry. */
18
+ export interface MemoryEntry {
19
+ id: string
20
+ text: string
21
+ importance: number
22
+ accessCount: number
23
+ createdAt: number
24
+ lastAccessAt: number
25
+ }
26
+
27
+ export interface MemoryDirectorConfig {
28
+ /** Provider to use for memory decisions. */
29
+ provider: string
30
+ /** Model to use for memory decisions. */
31
+ model: string
32
+ /** Max tokens for the decision call. */
33
+ maxTokens: number
34
+ /** Cosine-similarity dedup threshold. */
35
+ dedupThreshold: number
36
+ /** Forget threshold on importance decay. */
37
+ forgetThreshold: number
38
+ /** Memory store file path. */
39
+ storePath: string
40
+ /** Enable turn-end auto decisions. */
41
+ auto: boolean
42
+ }
43
+
44
+ const DEFAULT_CONFIG: MemoryDirectorConfig = {
45
+ provider: 'deepseek-official',
46
+ model: 'deepseek-v4-flash',
47
+ maxTokens: 256,
48
+ dedupThreshold: 0.8,
49
+ forgetThreshold: 0.1,
50
+ storePath: '~/.dsh/memory.json',
51
+ auto: true,
52
+ }
53
+
54
+ const DECISION_PROMPT = `You are a memory director. Analyze this conversation turn and decide what to remember long-term.
55
+ Output ONLY JSON:
56
+ {"remember": ["concise fact worth keeping (preferences, IDs, paths, decisions, parameters)"],
57
+ "forget": ["stale or irrelevant facts from this turn"],
58
+ "importance": 0.0-1.0}
59
+ Rules: remember only reusable facts; drop chatter; never invent facts; keep each item under 60 chars.`
60
+
61
+ /**
62
+ * MemoryDirectorService — exposes ctx.memory (remember/search/forget)
63
+ * and hooks the agent loop to auto-manage memory.
64
+ */
65
+ export class MemoryDirectorService {
66
+ static inject = ['llm', 'agents']
67
+
68
+ static Config: z<MemoryDirectorConfig> = z.object({
69
+ provider: z.string().default('deepseek-official'),
70
+ model: z.string().default('deepseek-v4-flash'),
71
+ maxTokens: z.number().default(256),
72
+ dedupThreshold: z.number().default(0.8),
73
+ forgetThreshold: z.number().default(0.1),
74
+ storePath: z.string().default('~/.dsh/memory.json'),
75
+ auto: z.boolean().default(true),
76
+ })
77
+
78
+ readonly config: MemoryDirectorConfig
79
+ private memories: MemoryEntry[] = []
80
+ private readonly llm: any
81
+
82
+ constructor(private readonly ctx: Context, config: Partial<MemoryDirectorConfig> = {}) {
83
+ this.config = { ...DEFAULT_CONFIG, ...config }
84
+ this.llm = ctx.get('llm')
85
+ this._load()
86
+ // Declare the service on ctx (Cordis 4: provide before set).
87
+ ;(ctx as any).provide?.('memory')
88
+ ctx.set('memory', this)
89
+ if (this.config.auto) this._hookAgentLoop()
90
+ }
91
+
92
+ // ===== Public API (ctx.memory) =====
93
+
94
+ remember(text: string, importance = 0.7): MemoryEntry {
95
+ const clean = text.trim().slice(0, 200)
96
+ if (!clean) return null as any
97
+ // Dedup: rough token-overlap similarity.
98
+ if (this._findSimilar(clean)) return this._findSimilar(clean)!
99
+ const entry: MemoryEntry = {
100
+ id: `mem-${Date.now()}-${Math.floor(Math.random() * 1e6)}`,
101
+ text: clean,
102
+ importance,
103
+ accessCount: 0,
104
+ createdAt: Date.now(),
105
+ lastAccessAt: Date.now(),
106
+ }
107
+ this.memories.push(entry)
108
+ this._save()
109
+ this.ctx.logger.info(`[memory-director] + remember: ${clean.slice(0, 40)}`)
110
+ return entry
111
+ }
112
+
113
+ forget(id: string): boolean {
114
+ const before = this.memories.length
115
+ this.memories = this.memories.filter((m) => m.id !== id)
116
+ if (this.memories.length !== before) {
117
+ this._save()
118
+ this.ctx.logger.info(`[memory-director] - forget: ${id}`)
119
+ return true
120
+ }
121
+ return false
122
+ }
123
+
124
+ search(query: string, limit = 5): MemoryEntry[] {
125
+ // Simple relevance: shared tokens between query and memory.
126
+ const qTokens = new Set(this._tokenize(query))
127
+ const scored = this.memories.map((m) => {
128
+ const mTokens = new Set(this._tokenize(m.text))
129
+ let hits = 0
130
+ qTokens.forEach((t) => { if (mTokens.has(t)) hits++ })
131
+ return { m, score: hits / Math.max(qTokens.size, 1) }
132
+ })
133
+ scored.sort((a, b) => b.score - a.score)
134
+ const top = scored.slice(0, limit)
135
+ // Touch access.
136
+ top.forEach(({ m }) => {
137
+ m.accessCount++
138
+ m.lastAccessAt = Date.now()
139
+ })
140
+ if (top.length) this._save()
141
+ return top.map(({ m }) => m)
142
+ }
143
+
144
+ all(): MemoryEntry[] {
145
+ return [...this.memories]
146
+ }
147
+
148
+ clear(): void {
149
+ this.memories = []
150
+ this._save()
151
+ }
152
+
153
+ // ===== LLM decision (the MemoryDirector core) =====
154
+
155
+ async decide(turnText: string): Promise<{ remember: string[]; forget: string[]; importance: number }> {
156
+ try {
157
+ const resp = await this.llm.chat({
158
+ model: this.config.model,
159
+ provider: this.config.provider,
160
+ messages: [
161
+ { role: 'system', content: DECISION_PROMPT },
162
+ { role: 'user', content: turnText.slice(0, 6000) },
163
+ ],
164
+ maxTokens: this.config.maxTokens,
165
+ temperature: 0.2,
166
+ })
167
+ const content = typeof resp === 'string' ? resp : resp?.content ?? ''
168
+ const jsonMatch = content.match(/\{[\s\S]*\}/)
169
+ if (!jsonMatch) return { remember: [], forget: [], importance: 0.5 }
170
+ const parsed = JSON.parse(jsonMatch[0])
171
+ return {
172
+ remember: Array.isArray(parsed.remember) ? parsed.remember.filter((x: unknown) => typeof x === 'string') : [],
173
+ forget: Array.isArray(parsed.forget) ? parsed.forget.filter((x: unknown) => typeof x === 'string') : [],
174
+ importance: typeof parsed.importance === 'number' ? Math.max(0, Math.min(1, parsed.importance)) : 0.5,
175
+ }
176
+ } catch (e) {
177
+ this.ctx.logger.warn('[memory-director] decision failed:', e)
178
+ return { remember: [], forget: [], importance: 0.5 }
179
+ }
180
+ }
181
+
182
+ // ===== Agent loop hooks =====
183
+
184
+ private _hookAgentLoop(): void {
185
+ const { ctx } = this
186
+ // After each turn: extract durable facts.
187
+ ctx.on('agent/turn-stopping' as any, async (payload: { agent: Agent; turn: number }) => {
188
+ const session = (payload.agent as any).session
189
+ const turnText = this._turnText(session, payload.turn)
190
+ if (turnText.length < 20) return
191
+ const decision = await this.decide(turnText)
192
+ for (const item of decision.remember) this.remember(item, decision.importance)
193
+ // Forget matched stale entries.
194
+ for (const item of decision.forget) {
195
+ const hit = this.memories.find((m) => m.text.includes(item.slice(0, 20)))
196
+ if (hit) this.forget(hit.id)
197
+ }
198
+ })
199
+
200
+ // Before each step: inject relevant memories.
201
+ ctx.on('agent/pre-step' as any, async (
202
+ payload: { agent: Agent; messages: any[] },
203
+ next: () => Promise<unknown>,
204
+ ) => {
205
+ try {
206
+ const lastUser = [...payload.messages].reverse().find((m) => m.role === 'user')
207
+ const query = lastUser?.content ?? ''
208
+ const relevant = this.search(String(query).slice(0, 500), 4)
209
+ if (relevant.length) {
210
+ const memoryBlock = {
211
+ role: 'system' as const,
212
+ content:
213
+ '[memory-director] Relevant long-term memories:\n' +
214
+ relevant.map((m, i) => `${i + 1}. ${m.text}`).join('\n') +
215
+ '\nUse them if relevant; ignore if not.',
216
+ }
217
+ payload.messages.unshift(memoryBlock)
218
+ }
219
+ } catch (e) {
220
+ this.ctx.logger.warn('[memory-director] pre-step hook error:', e)
221
+ }
222
+ return next()
223
+ })
224
+ }
225
+
226
+ // ===== Internals =====
227
+
228
+ private _turnText(session: any, turn: number): string {
229
+ try {
230
+ const log = session.log ?? []
231
+ return log
232
+ .filter((e: any) => e.turn === turn || (e.turn === undefined && e.type !== 'compaction/start' && e.type !== 'compaction/end'))
233
+ .map((e: any) => this._eventText(e))
234
+ .filter(Boolean)
235
+ .join('\n')
236
+ } catch {
237
+ return ''
238
+ }
239
+ }
240
+
241
+ private _eventText(ev: any): string {
242
+ if (typeof ev?.content === 'string') return ev.content
243
+ if (Array.isArray(ev?.content)) {
244
+ return ev.content.map((b: any) => (typeof b === 'string' ? b : b?.text ?? '')).join(' ')
245
+ }
246
+ return ''
247
+ }
248
+
249
+ private _tokenize(text: string): string[] {
250
+ return String(text).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean).slice(0, 100)
251
+ }
252
+
253
+ private _findSimilar(text: string): MemoryEntry | null {
254
+ const t = new Set(this._tokenize(text))
255
+ for (const m of this.memories) {
256
+ const mt = new Set(this._tokenize(m.text))
257
+ let hits = 0
258
+ t.forEach((x) => { if (mt.has(x)) hits++ })
259
+ const sim = hits / Math.max(Math.max(t.size, mt.size), 1)
260
+ if (sim > this.config.dedupThreshold) return m
261
+ }
262
+ return null
263
+ }
264
+
265
+ private _load(): void {
266
+ try {
267
+ const fs = require('node:fs')
268
+ const path = this.config.storePath.replace(/^~/, require('node:os').homedir())
269
+ if (fs.existsSync(path)) {
270
+ this.memories = JSON.parse(fs.readFileSync(path, 'utf8'))
271
+ // Apply forgetting: drop low-importance old entries.
272
+ this.memories = this.memories.filter((m) => m.importance >= this.config.forgetThreshold)
273
+ }
274
+ } catch (e) {
275
+ this.ctx.logger.warn('[memory-director] load failed:', e)
276
+ }
277
+ }
278
+
279
+ private _save(): void {
280
+ try {
281
+ const fs = require('node:fs')
282
+ const path = this.config.storePath.replace(/^~/, require('node:os').homedir())
283
+ fs.mkdirSync(require('node:path').dirname(path), { recursive: true })
284
+ fs.writeFileSync(path, JSON.stringify(this.memories, null, 2))
285
+ } catch (e) {
286
+ this.ctx.logger.warn('[memory-director] save failed:', e)
287
+ }
288
+ }
289
+ }
290
+
291
+ export default MemoryDirectorService