File size: 9,887 Bytes
3bef79f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
/**
 * dsh-memory-director — MemoryDirector plugin for DeepSeek Harness.
 *
 * Official Harness compacts history via summarization, but has NO concept of
 * "which facts are worth remembering across sessions". This plugin adds the
 * AgentFrame MemoryDirector: after each turn, an LLM decides what to
 * remember / forget; before each step, relevant memories are injected into
 * the model context.
 *
 * @module @agentframe/dsh-memory-director
 */

import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'

/** One persisted memory entry. */
export interface MemoryEntry {
  id: string
  text: string
  importance: number
  accessCount: number
  createdAt: number
  lastAccessAt: number
}

export interface MemoryDirectorConfig {
  /** Provider to use for memory decisions. */
  provider: string
  /** Model to use for memory decisions. */
  model: string
  /** Max tokens for the decision call. */
  maxTokens: number
  /** Cosine-similarity dedup threshold. */
  dedupThreshold: number
  /** Forget threshold on importance decay. */
  forgetThreshold: number
  /** Memory store file path. */
  storePath: string
  /** Enable turn-end auto decisions. */
  auto: boolean
}

const DEFAULT_CONFIG: MemoryDirectorConfig = {
  provider: 'deepseek-official',
  model: 'deepseek-v4-flash',
  maxTokens: 256,
  dedupThreshold: 0.8,
  forgetThreshold: 0.1,
  storePath: '~/.dsh/memory.json',
  auto: true,
}

const DECISION_PROMPT = `You are a memory director. Analyze this conversation turn and decide what to remember long-term.
Output ONLY JSON:
{"remember": ["concise fact worth keeping (preferences, IDs, paths, decisions, parameters)"],
 "forget": ["stale or irrelevant facts from this turn"],
 "importance": 0.0-1.0}
Rules: remember only reusable facts; drop chatter; never invent facts; keep each item under 60 chars.`

/**
 * MemoryDirectorService — exposes ctx.memory (remember/search/forget)
 * and hooks the agent loop to auto-manage memory.
 */
export class MemoryDirectorService {
  static inject = ['llm', 'agents']

  static Config: z<MemoryDirectorConfig> = z.object({
    provider: z.string().default('deepseek-official'),
    model: z.string().default('deepseek-v4-flash'),
    maxTokens: z.number().default(256),
    dedupThreshold: z.number().default(0.8),
    forgetThreshold: z.number().default(0.1),
    storePath: z.string().default('~/.dsh/memory.json'),
    auto: z.boolean().default(true),
  })

  readonly config: MemoryDirectorConfig
  private memories: MemoryEntry[] = []
  private readonly llm: any

  constructor(private readonly ctx: Context, config: Partial<MemoryDirectorConfig> = {}) {
    this.config = { ...DEFAULT_CONFIG, ...config }
    this.llm = ctx.get('llm')
    this._load()
    // Declare the service on ctx (Cordis 4: provide before set).
    ;(ctx as any).provide?.('memory')
    ctx.set('memory', this)
    if (this.config.auto) this._hookAgentLoop()
  }

  // ===== Public API (ctx.memory) =====

  remember(text: string, importance = 0.7): MemoryEntry {
    const clean = text.trim().slice(0, 200)
    if (!clean) return null as any
    // Dedup: rough token-overlap similarity.
    if (this._findSimilar(clean)) return this._findSimilar(clean)!
    const entry: MemoryEntry = {
      id: `mem-${Date.now()}-${Math.floor(Math.random() * 1e6)}`,
      text: clean,
      importance,
      accessCount: 0,
      createdAt: Date.now(),
      lastAccessAt: Date.now(),
    }
    this.memories.push(entry)
    this._save()
    this.ctx.logger.info(`[memory-director] + remember: ${clean.slice(0, 40)}`)
    return entry
  }

  forget(id: string): boolean {
    const before = this.memories.length
    this.memories = this.memories.filter((m) => m.id !== id)
    if (this.memories.length !== before) {
      this._save()
      this.ctx.logger.info(`[memory-director] - forget: ${id}`)
      return true
    }
    return false
  }

  search(query: string, limit = 5): MemoryEntry[] {
    // Simple relevance: shared tokens between query and memory.
    const qTokens = new Set(this._tokenize(query))
    const scored = this.memories.map((m) => {
      const mTokens = new Set(this._tokenize(m.text))
      let hits = 0
      qTokens.forEach((t) => { if (mTokens.has(t)) hits++ })
      return { m, score: hits / Math.max(qTokens.size, 1) }
    })
    scored.sort((a, b) => b.score - a.score)
    const top = scored.slice(0, limit)
    // Touch access.
    top.forEach(({ m }) => {
      m.accessCount++
      m.lastAccessAt = Date.now()
    })
    if (top.length) this._save()
    return top.map(({ m }) => m)
  }

  all(): MemoryEntry[] {
    return [...this.memories]
  }

  clear(): void {
    this.memories = []
    this._save()
  }

  // ===== LLM decision (the MemoryDirector core) =====

  async decide(turnText: string): Promise<{ remember: string[]; forget: string[]; importance: number }> {
    try {
      const resp = await this.llm.chat({
        model: this.config.model,
        provider: this.config.provider,
        messages: [
          { role: 'system', content: DECISION_PROMPT },
          { role: 'user', content: turnText.slice(0, 6000) },
        ],
        maxTokens: this.config.maxTokens,
        temperature: 0.2,
      })
      const content = typeof resp === 'string' ? resp : resp?.content ?? ''
      const jsonMatch = content.match(/\{[\s\S]*\}/)
      if (!jsonMatch) return { remember: [], forget: [], importance: 0.5 }
      const parsed = JSON.parse(jsonMatch[0])
      return {
        remember: Array.isArray(parsed.remember) ? parsed.remember.filter((x: unknown) => typeof x === 'string') : [],
        forget: Array.isArray(parsed.forget) ? parsed.forget.filter((x: unknown) => typeof x === 'string') : [],
        importance: typeof parsed.importance === 'number' ? Math.max(0, Math.min(1, parsed.importance)) : 0.5,
      }
    } catch (e) {
      this.ctx.logger.warn('[memory-director] decision failed:', e)
      return { remember: [], forget: [], importance: 0.5 }
    }
  }

  // ===== Agent loop hooks =====

  private _hookAgentLoop(): void {
    const { ctx } = this
    // After each turn: extract durable facts.
    ctx.on('agent/turn-stopping' as any, async (payload: { agent: Agent; turn: number }) => {
      const session = (payload.agent as any).session
      const turnText = this._turnText(session, payload.turn)
      if (turnText.length < 20) return
      const decision = await this.decide(turnText)
      for (const item of decision.remember) this.remember(item, decision.importance)
      // Forget matched stale entries.
      for (const item of decision.forget) {
        const hit = this.memories.find((m) => m.text.includes(item.slice(0, 20)))
        if (hit) this.forget(hit.id)
      }
    })

    // Before each step: inject relevant memories.
    ctx.on('agent/pre-step' as any, async (
      payload: { agent: Agent; messages: any[] },
      next: () => Promise<unknown>,
    ) => {
      try {
        const lastUser = [...payload.messages].reverse().find((m) => m.role === 'user')
        const query = lastUser?.content ?? ''
        const relevant = this.search(String(query).slice(0, 500), 4)
        if (relevant.length) {
          const memoryBlock = {
            role: 'system' as const,
            content:
              '[memory-director] Relevant long-term memories:\n' +
              relevant.map((m, i) => `${i + 1}. ${m.text}`).join('\n') +
              '\nUse them if relevant; ignore if not.',
          }
          payload.messages.unshift(memoryBlock)
        }
      } catch (e) {
        this.ctx.logger.warn('[memory-director] pre-step hook error:', e)
      }
      return next()
    })
  }

  // ===== Internals =====

  private _turnText(session: any, turn: number): string {
    try {
      const log = session.log ?? []
      return log
        .filter((e: any) => e.turn === turn || (e.turn === undefined && e.type !== 'compaction/start' && e.type !== 'compaction/end'))
        .map((e: any) => this._eventText(e))
        .filter(Boolean)
        .join('\n')
    } catch {
      return ''
    }
  }

  private _eventText(ev: any): string {
    if (typeof ev?.content === 'string') return ev.content
    if (Array.isArray(ev?.content)) {
      return ev.content.map((b: any) => (typeof b === 'string' ? b : b?.text ?? '')).join(' ')
    }
    return ''
  }

  private _tokenize(text: string): string[] {
    return String(text).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean).slice(0, 100)
  }

  private _findSimilar(text: string): MemoryEntry | null {
    const t = new Set(this._tokenize(text))
    for (const m of this.memories) {
      const mt = new Set(this._tokenize(m.text))
      let hits = 0
      t.forEach((x) => { if (mt.has(x)) hits++ })
      const sim = hits / Math.max(Math.max(t.size, mt.size), 1)
      if (sim > this.config.dedupThreshold) return m
    }
    return null
  }

  private _load(): void {
    try {
      const fs = require('node:fs')
      const path = this.config.storePath.replace(/^~/, require('node:os').homedir())
      if (fs.existsSync(path)) {
        this.memories = JSON.parse(fs.readFileSync(path, 'utf8'))
        // Apply forgetting: drop low-importance old entries.
        this.memories = this.memories.filter((m) => m.importance >= this.config.forgetThreshold)
      }
    } catch (e) {
      this.ctx.logger.warn('[memory-director] load failed:', e)
    }
  }

  private _save(): void {
    try {
      const fs = require('node:fs')
      const path = this.config.storePath.replace(/^~/, require('node:os').homedir())
      fs.mkdirSync(require('node:path').dirname(path), { recursive: true })
      fs.writeFileSync(path, JSON.stringify(this.memories, null, 2))
    } catch (e) {
      this.ctx.logger.warn('[memory-director] save failed:', e)
    }
  }
}

export default MemoryDirectorService