@onmars/lunar-core 0.1.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/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +32 -0
- package/src/__tests__/clear-command.test.ts +214 -0
- package/src/__tests__/command-handler.test.ts +169 -0
- package/src/__tests__/compact-command.test.ts +80 -0
- package/src/__tests__/config-command.test.ts +240 -0
- package/src/__tests__/config-loader.test.ts +1512 -0
- package/src/__tests__/config.test.ts +429 -0
- package/src/__tests__/cron-command.test.ts +418 -0
- package/src/__tests__/cron-parser.test.ts +259 -0
- package/src/__tests__/daemon.test.ts +346 -0
- package/src/__tests__/dedup.test.ts +404 -0
- package/src/__tests__/e2e-sanitization.ts +168 -0
- package/src/__tests__/e2e-skill-loader.test.ts +176 -0
- package/src/__tests__/fixtures/AGENTS.md +4 -0
- package/src/__tests__/fixtures/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
- package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
- package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
- package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
- package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
- package/src/__tests__/hook-runner.test.ts +1689 -0
- package/src/__tests__/input-sanitization.test.ts +367 -0
- package/src/__tests__/logger.test.ts +163 -0
- package/src/__tests__/memory-orchestrator.test.ts +552 -0
- package/src/__tests__/model-catalog.test.ts +215 -0
- package/src/__tests__/model-command.test.ts +185 -0
- package/src/__tests__/moon-loader.test.ts +398 -0
- package/src/__tests__/ping-command.test.ts +85 -0
- package/src/__tests__/plugin.test.ts +258 -0
- package/src/__tests__/remind-command.test.ts +368 -0
- package/src/__tests__/reset-command.test.ts +92 -0
- package/src/__tests__/router.test.ts +1246 -0
- package/src/__tests__/scheduler.test.ts +469 -0
- package/src/__tests__/security.test.ts +214 -0
- package/src/__tests__/session-meta.test.ts +101 -0
- package/src/__tests__/session-tracker.test.ts +389 -0
- package/src/__tests__/session.test.ts +241 -0
- package/src/__tests__/skill-loader.test.ts +153 -0
- package/src/__tests__/status-command.test.ts +153 -0
- package/src/__tests__/stop-command.test.ts +60 -0
- package/src/__tests__/think-command.test.ts +146 -0
- package/src/__tests__/usage-api.test.ts +222 -0
- package/src/__tests__/usage-command-api-fail.test.ts +48 -0
- package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
- package/src/__tests__/usage-command.test.ts +173 -0
- package/src/__tests__/whoami-command.test.ts +124 -0
- package/src/index.ts +122 -0
- package/src/lib/command-handler.ts +135 -0
- package/src/lib/commands/clear.ts +69 -0
- package/src/lib/commands/compact.ts +14 -0
- package/src/lib/commands/config-show.ts +49 -0
- package/src/lib/commands/cron.ts +118 -0
- package/src/lib/commands/help.ts +26 -0
- package/src/lib/commands/model.ts +71 -0
- package/src/lib/commands/ping.ts +24 -0
- package/src/lib/commands/remind.ts +75 -0
- package/src/lib/commands/status.ts +118 -0
- package/src/lib/commands/stop.ts +18 -0
- package/src/lib/commands/think.ts +42 -0
- package/src/lib/commands/usage.ts +56 -0
- package/src/lib/commands/whoami.ts +23 -0
- package/src/lib/config-loader.ts +1449 -0
- package/src/lib/config.ts +202 -0
- package/src/lib/cron-parser.ts +388 -0
- package/src/lib/daemon.ts +216 -0
- package/src/lib/dedup.ts +414 -0
- package/src/lib/hook-runner.ts +1270 -0
- package/src/lib/logger.ts +55 -0
- package/src/lib/memory-orchestrator.ts +415 -0
- package/src/lib/model-catalog.ts +240 -0
- package/src/lib/moon-loader.ts +291 -0
- package/src/lib/plugin.ts +148 -0
- package/src/lib/router.ts +1135 -0
- package/src/lib/scheduler.ts +422 -0
- package/src/lib/security.ts +259 -0
- package/src/lib/session-tracker.ts +222 -0
- package/src/lib/session.ts +158 -0
- package/src/lib/skill-loader.ts +166 -0
- package/src/lib/usage-api.ts +145 -0
- package/src/types/agent.ts +86 -0
- package/src/types/channel.ts +93 -0
- package/src/types/index.ts +32 -0
- package/src/types/memory.ts +92 -0
- package/src/types/moon.ts +56 -0
- package/src/types/voice.ts +74 -0
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HookRunner — Evaluates conditions and executes lifecycle hook actions.
|
|
3
|
+
*
|
|
4
|
+
* Design:
|
|
5
|
+
* - Best-effort: hook failures are logged but never block the main flow
|
|
6
|
+
* - Declared order: hooks execute top-to-bottom per lifecycle event
|
|
7
|
+
* - Guard-first: conditions checked before execution (fail = silent skip)
|
|
8
|
+
* - Mutable context: actions modify results, facts, summaries in-place
|
|
9
|
+
* - Extensible: new actions are functions in the ACTION_REGISTRY
|
|
10
|
+
*
|
|
11
|
+
* Built-in actions (v1):
|
|
12
|
+
* promote — Transfer knowledge from provider A → B (afterSessionEnd, afterSave)
|
|
13
|
+
* boost — Adjust recall scores by recency (afterRecall)
|
|
14
|
+
* context — Pre-load context from a provider (sessionStart)
|
|
15
|
+
* summarize — Save session summary to a provider (sessionEnd)
|
|
16
|
+
*
|
|
17
|
+
* Guards:
|
|
18
|
+
* providersAvailable — Check that named providers are registered (not health-checked for speed)
|
|
19
|
+
* minMessages — Minimum session message count
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Fact, MemoryProvider, MemoryResult } from '../types/memory'
|
|
23
|
+
import type { HookCondition, HookEntry, HooksConfig, LifecycleEvent } from './config-loader'
|
|
24
|
+
import { log } from './logger'
|
|
25
|
+
|
|
26
|
+
// ════════════════════════════════════════════════════════════
|
|
27
|
+
// Types
|
|
28
|
+
// ════════════════════════════════════════════════════════════
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Function signature for one-shot LLM calls.
|
|
32
|
+
* Used by summarize/promote hooks for intelligent summarization.
|
|
33
|
+
* Provided externally (e.g., CLI wrapper) to keep core agent-agnostic.
|
|
34
|
+
*/
|
|
35
|
+
export type LLMFunction = (prompt: string) => Promise<string>
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Mutable context passed through hooks at each lifecycle event.
|
|
39
|
+
* Actions can read and modify any field.
|
|
40
|
+
*/
|
|
41
|
+
export interface HookContext {
|
|
42
|
+
/** Current session message count */
|
|
43
|
+
messageCount: number
|
|
44
|
+
/** Session messages (for summarize, promote) */
|
|
45
|
+
messages?: Array<{ role: string; content: string }>
|
|
46
|
+
/** Recall results — mutable (afterRecall boost modifies scores) */
|
|
47
|
+
results?: MemoryResult[]
|
|
48
|
+
/** Search query — mutable (beforeRecall can expand/modify) */
|
|
49
|
+
query?: string
|
|
50
|
+
/** Facts being saved — mutable (beforeSave can classify/route) */
|
|
51
|
+
facts?: Fact[]
|
|
52
|
+
/** Session summary text */
|
|
53
|
+
sessionSummary?: string
|
|
54
|
+
/** Session topics */
|
|
55
|
+
topics?: string[]
|
|
56
|
+
/** Optional LLM function — injected by HookRunner when available */
|
|
57
|
+
llm?: LLMFunction
|
|
58
|
+
/** Episode ID for linking all facts from a session (set by episode hook at sessionStart) */
|
|
59
|
+
episodeId?: string
|
|
60
|
+
/** Session date YYYY-MM-DD (set by episode hook at sessionStart) */
|
|
61
|
+
sessionDate?: string
|
|
62
|
+
/** Channel name (for episode lookup) */
|
|
63
|
+
channel?: string
|
|
64
|
+
/** Fact IDs created during this session (for updating episode at close) */
|
|
65
|
+
createdFactIds?: string[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Function signature for action executors.
|
|
70
|
+
* Receives the hook definition, mutable context, and provider map.
|
|
71
|
+
*/
|
|
72
|
+
type ActionExecutor = (
|
|
73
|
+
hook: HookEntry,
|
|
74
|
+
context: HookContext,
|
|
75
|
+
providers: ReadonlyMap<string, MemoryProvider>,
|
|
76
|
+
) => Promise<void>
|
|
77
|
+
|
|
78
|
+
// ════════════════════════════════════════════════════════════
|
|
79
|
+
// Time window parser
|
|
80
|
+
// ════════════════════════════════════════════════════════════
|
|
81
|
+
|
|
82
|
+
const TIME_MULTIPLIERS: Record<string, number> = {
|
|
83
|
+
s: 1_000,
|
|
84
|
+
m: 60_000,
|
|
85
|
+
h: 3_600_000,
|
|
86
|
+
d: 86_400_000,
|
|
87
|
+
w: 604_800_000,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse a human-readable time window to milliseconds.
|
|
92
|
+
* Supports: 30s, 5m, 1h, 24h, 7d, 2w.
|
|
93
|
+
* Returns 24h if format is invalid.
|
|
94
|
+
*/
|
|
95
|
+
export function parseTimeWindow(window: string): number {
|
|
96
|
+
const match = window.match(/^(\d+)(s|m|h|d|w)$/)
|
|
97
|
+
if (!match) return TIME_MULTIPLIERS.d // default: 24h
|
|
98
|
+
|
|
99
|
+
const value = parseInt(match[1], 10)
|
|
100
|
+
return value * (TIME_MULTIPLIERS[match[2]] ?? TIME_MULTIPLIERS.d)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ════════════════════════════════════════════════════════════
|
|
104
|
+
// Guard evaluation
|
|
105
|
+
// ════════════════════════════════════════════════════════════
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Evaluate hook guard conditions.
|
|
109
|
+
*
|
|
110
|
+
* Returns true if:
|
|
111
|
+
* - No guard defined (always run)
|
|
112
|
+
* - All conditions are met
|
|
113
|
+
*
|
|
114
|
+
* Returns false if any condition fails (silent skip).
|
|
115
|
+
*
|
|
116
|
+
* Note: `providersAvailable` checks registration only — not health.
|
|
117
|
+
* If a provider is registered but unhealthy, the action itself will fail
|
|
118
|
+
* and be caught by best-effort error handling. This keeps guards fast
|
|
119
|
+
* (no async health calls on the critical path).
|
|
120
|
+
*/
|
|
121
|
+
export function evaluateGuard(
|
|
122
|
+
guard: HookCondition | undefined,
|
|
123
|
+
context: HookContext,
|
|
124
|
+
providers: ReadonlyMap<string, MemoryProvider>,
|
|
125
|
+
): boolean {
|
|
126
|
+
if (!guard) return true
|
|
127
|
+
|
|
128
|
+
// Check providersAvailable — all must be registered
|
|
129
|
+
if (guard.providersAvailable?.length) {
|
|
130
|
+
for (const name of guard.providersAvailable) {
|
|
131
|
+
if (!providers.has(name)) return false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check minMessages
|
|
136
|
+
if (guard.minMessages !== undefined) {
|
|
137
|
+
if (context.messageCount < guard.minMessages) return false
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ════════════════════════════════════════════════════════════
|
|
144
|
+
// Constants
|
|
145
|
+
// ════════════════════════════════════════════════════════════
|
|
146
|
+
|
|
147
|
+
/** Maximum transcript characters sent to LLM for summarization (~1000 tokens) */
|
|
148
|
+
const MAX_TRANSCRIPT_CHARS = 4000
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* System prompt for LLM-powered session summarization.
|
|
152
|
+
* Designed to produce dense, factual summaries suitable for long-term memory.
|
|
153
|
+
*
|
|
154
|
+
* IMPORTANT: Starts with explicit instruction to NOT use any tools.
|
|
155
|
+
* When run via Claude CLI in print mode, the agent has access to tools
|
|
156
|
+
* (file read, bash, etc.) and will use them for summarization tasks
|
|
157
|
+
* unless explicitly told not to. This wastes turns and produces empty results.
|
|
158
|
+
*/
|
|
159
|
+
const SUMMARIZE_PROMPT = `Do not use any tools. Do not read files. Do not run commands. Reply with plain text only.
|
|
160
|
+
|
|
161
|
+
You are a factual session logger. Extract ONLY what happened in the conversation below.
|
|
162
|
+
|
|
163
|
+
Output format: plain text, one fact per line, prefix with category:
|
|
164
|
+
DECISION: <what was decided and why>
|
|
165
|
+
INFO: <key fact, preference, or context shared>
|
|
166
|
+
PROBLEM: <issue encountered> -> <resolution if any>
|
|
167
|
+
PENDING: <unresolved item or next step>
|
|
168
|
+
TECHNICAL: <architecture, code, or config detail>
|
|
169
|
+
|
|
170
|
+
Rules:
|
|
171
|
+
- Report what the participants said and did. Do not add opinions, analysis, or commentary.
|
|
172
|
+
- No markdown formatting (no bold, no italic, no headers, no horizontal rules).
|
|
173
|
+
- Each line must be a complete, standalone fact.
|
|
174
|
+
- Do not include conversational filler (greetings, acknowledgments, rhetorical questions).
|
|
175
|
+
- Do not paraphrase into your own voice. Stick to what was explicitly stated.
|
|
176
|
+
- Match the conversation language.
|
|
177
|
+
- Max 20 lines.
|
|
178
|
+
|
|
179
|
+
Conversation:
|
|
180
|
+
`
|
|
181
|
+
|
|
182
|
+
const JOURNAL_PROMPT = `Do not use any tools. Do not read files. Do not run commands. Reply with plain text only.
|
|
183
|
+
|
|
184
|
+
You are writing a personal journal entry for an AI companion, reflecting on a session with Mars (your human partner).
|
|
185
|
+
|
|
186
|
+
This is YOUR diary — not a report, not data. Write as yourself: what surprised you, what you learned, what you'd do differently, what matters.
|
|
187
|
+
|
|
188
|
+
Rules:
|
|
189
|
+
- First person (I/me). This is your inner voice.
|
|
190
|
+
- Honest and specific. Not generic observations.
|
|
191
|
+
- 3-5 sentences max. Dense, not verbose.
|
|
192
|
+
- No markdown formatting. No headers. No lists.
|
|
193
|
+
- Match the conversation language.
|
|
194
|
+
- If nothing meaningful happened, say that honestly.
|
|
195
|
+
|
|
196
|
+
Conversation:
|
|
197
|
+
`
|
|
198
|
+
|
|
199
|
+
const EXPERIENCE_PROMPT = `Do not use any tools. Do not read files. Do not run commands. Reply with plain text only.
|
|
200
|
+
|
|
201
|
+
You are capturing the emotional tone of a session for an AI companion.
|
|
202
|
+
|
|
203
|
+
Write 1-2 sentences about how this session FELT — the energy, the vibe, the mood. Not what happened (that's the summary), but the color of the experience.
|
|
204
|
+
|
|
205
|
+
Rules:
|
|
206
|
+
- First person.
|
|
207
|
+
- Impressionistic, not analytical. Think "warm and focused" not "productive meeting."
|
|
208
|
+
- No markdown. No lists. 1-2 sentences only.
|
|
209
|
+
- Match the conversation language.
|
|
210
|
+
- If you felt nothing notable, say that.
|
|
211
|
+
|
|
212
|
+
Conversation:
|
|
213
|
+
`
|
|
214
|
+
|
|
215
|
+
// ════════════════════════════════════════════════════════════
|
|
216
|
+
// Built-in actions
|
|
217
|
+
// ════════════════════════════════════════════════════════════
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* `promote` — Transfer knowledge from one provider to another.
|
|
221
|
+
*
|
|
222
|
+
* Use case: After session ends, extract facts from Engram (session memory)
|
|
223
|
+
* and save them to Brain (semantic memory).
|
|
224
|
+
*
|
|
225
|
+
* Fields: from, to, via
|
|
226
|
+
* Events: afterSessionEnd, afterSave
|
|
227
|
+
*
|
|
228
|
+
* Strategies (via):
|
|
229
|
+
* 'extraction' — Uses session messages/summary to create facts in target
|
|
230
|
+
* 'full' — Rich summary + individual observations from source (recommended)
|
|
231
|
+
* (default) — Recalls from source and saves to target
|
|
232
|
+
*/
|
|
233
|
+
const executePromote: ActionExecutor = async (hook, context, providers) => {
|
|
234
|
+
const { from, to, via } = hook
|
|
235
|
+
if (!from || !to) {
|
|
236
|
+
log.warn({ action: 'promote' }, 'Hook promote: requires "from" and "to" — skipping')
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const sourceProvider = providers.get(from)
|
|
241
|
+
const targetProvider = providers.get(to)
|
|
242
|
+
|
|
243
|
+
if (!sourceProvider) {
|
|
244
|
+
log.warn({ from, action: 'promote' }, 'Hook promote: source provider not found — skipping')
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
if (!targetProvider) {
|
|
248
|
+
log.warn({ to, action: 'promote' }, 'Hook promote: target provider not found — skipping')
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (via === 'full') {
|
|
253
|
+
// Full strategy: two sources of individual facts:
|
|
254
|
+
// 1. Parse structured lines from LLM summary (DECISION/INFO/PROBLEM/PENDING/TECHNICAL)
|
|
255
|
+
// 2. Search source provider for observations by topic
|
|
256
|
+
// The session-level summary is NOT saved here (summarize hook handles that).
|
|
257
|
+
const facts: Fact[] = []
|
|
258
|
+
const seenContent = new Set<string>()
|
|
259
|
+
|
|
260
|
+
// Category mapping from LLM prefix to fact category + importance
|
|
261
|
+
const PREFIX_MAP: Record<string, { category: string; importance: number }> = {
|
|
262
|
+
DECISION: { category: 'decision', importance: 7 },
|
|
263
|
+
INFO: { category: 'observation', importance: 5 },
|
|
264
|
+
PROBLEM: { category: 'learning', importance: 6 },
|
|
265
|
+
PENDING: { category: 'pending', importance: 6 },
|
|
266
|
+
TECHNICAL: { category: 'technical', importance: 5 },
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 1. Parse LLM summary lines into individual facts
|
|
270
|
+
if (context.sessionSummary) {
|
|
271
|
+
const lines = context.sessionSummary.split('\n').filter((l) => l.trim())
|
|
272
|
+
for (const line of lines) {
|
|
273
|
+
const match = line.match(/^\s*(DECISION|INFO|PROBLEM|PENDING|TECHNICAL):\s*(.+)/i)
|
|
274
|
+
if (!match) continue
|
|
275
|
+
|
|
276
|
+
const prefix = match[1].toUpperCase()
|
|
277
|
+
const factContent = match[2].trim()
|
|
278
|
+
if (!factContent || seenContent.has(factContent)) continue
|
|
279
|
+
seenContent.add(factContent)
|
|
280
|
+
|
|
281
|
+
const mapped = PREFIX_MAP[prefix] ?? { category: 'observation', importance: 5 }
|
|
282
|
+
facts.push({
|
|
283
|
+
content: factContent,
|
|
284
|
+
category: mapped.category,
|
|
285
|
+
domain: 'session',
|
|
286
|
+
source: from,
|
|
287
|
+
tags: ['promoted', `from:${from}`, prefix.toLowerCase()],
|
|
288
|
+
importance: mapped.importance,
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 2. Search source provider for additional observations by topic
|
|
294
|
+
const topics = context.topics ?? []
|
|
295
|
+
for (const topic of topics.slice(0, 5)) {
|
|
296
|
+
try {
|
|
297
|
+
const results = await sourceProvider.recall(topic, { limit: 3 })
|
|
298
|
+
for (const r of results) {
|
|
299
|
+
if (seenContent.has(r.content)) continue
|
|
300
|
+
seenContent.add(r.content)
|
|
301
|
+
|
|
302
|
+
facts.push({
|
|
303
|
+
content: r.content,
|
|
304
|
+
category: 'observation',
|
|
305
|
+
domain: 'session',
|
|
306
|
+
source: from,
|
|
307
|
+
tags: ['promoted', `from:${from}`, 'observation'],
|
|
308
|
+
importance: 5,
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
313
|
+
log.debug({ topic, error: errMsg }, 'Hook promote: topic search failed \u2014 continuing')
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Cap total promoted facts to avoid flooding target
|
|
318
|
+
const MAX_PROMOTED = 10
|
|
319
|
+
const toSave = facts.slice(0, MAX_PROMOTED)
|
|
320
|
+
|
|
321
|
+
if (toSave.length > 0) {
|
|
322
|
+
await targetProvider.save(toSave)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
log.info({ from, to, via, facts: toSave.length }, 'Hook promote: full transfer complete')
|
|
326
|
+
} else if (via === 'extraction' && context.messages?.length) {
|
|
327
|
+
// Extraction: use session summary or build from messages
|
|
328
|
+
const summary =
|
|
329
|
+
context.sessionSummary ||
|
|
330
|
+
`Session with ${context.messageCount} messages` +
|
|
331
|
+
(context.topics?.length ? ` covering: ${context.topics.join(', ')}` : '')
|
|
332
|
+
|
|
333
|
+
await targetProvider.save([
|
|
334
|
+
{
|
|
335
|
+
content: summary,
|
|
336
|
+
category: 'session-promotion',
|
|
337
|
+
domain: 'session',
|
|
338
|
+
source: from,
|
|
339
|
+
tags: ['promoted', `from:${from}`],
|
|
340
|
+
importance: 6,
|
|
341
|
+
},
|
|
342
|
+
])
|
|
343
|
+
|
|
344
|
+
log.info({ from, to, via, contentLength: summary.length }, 'Hook promote: extraction complete')
|
|
345
|
+
} else {
|
|
346
|
+
// Default: recall from source, save to target
|
|
347
|
+
const results = await sourceProvider.recall('session context', { limit: 5 })
|
|
348
|
+
if (results.length > 0) {
|
|
349
|
+
const facts: Fact[] = results.map((r) => ({
|
|
350
|
+
content: r.content,
|
|
351
|
+
category: 'promoted',
|
|
352
|
+
domain: 'session',
|
|
353
|
+
source: from,
|
|
354
|
+
tags: ['promoted', `from:${from}`],
|
|
355
|
+
importance: 5,
|
|
356
|
+
}))
|
|
357
|
+
await targetProvider.save(facts)
|
|
358
|
+
log.info({ from, to, factCount: facts.length }, 'Hook promote: recall-transfer complete')
|
|
359
|
+
} else {
|
|
360
|
+
log.debug({ from, to }, 'Hook promote: no results from source — nothing to transfer')
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* `boost` — Adjust recall result scores based on recency.
|
|
367
|
+
*
|
|
368
|
+
* Use case: After recall, boost recent results from a specific provider
|
|
369
|
+
* to prioritize fresh context over old knowledge.
|
|
370
|
+
*
|
|
371
|
+
* Fields: provider (optional filter), recencyWindow (e.g. '24h'), factor (e.g. 1.3)
|
|
372
|
+
* Events: afterRecall
|
|
373
|
+
*
|
|
374
|
+
* Modifies context.results in-place and re-sorts by score.
|
|
375
|
+
*/
|
|
376
|
+
const executeBoost: ActionExecutor = async (hook, context, _providers) => {
|
|
377
|
+
if (!context.results?.length) return
|
|
378
|
+
|
|
379
|
+
const { provider, recencyWindow, factor } = hook
|
|
380
|
+
const boostFactor = factor ?? 1.2
|
|
381
|
+
const windowMs = parseTimeWindow(recencyWindow ?? '24h')
|
|
382
|
+
const cutoff = new Date(Date.now() - windowMs)
|
|
383
|
+
|
|
384
|
+
let boosted = 0
|
|
385
|
+
for (const result of context.results) {
|
|
386
|
+
// Filter by provider source if specified
|
|
387
|
+
if (provider && result.source !== provider) continue
|
|
388
|
+
|
|
389
|
+
// Check recency — boost if stored within the window
|
|
390
|
+
if (result.storedAt && result.storedAt > cutoff) {
|
|
391
|
+
result.score = Math.min(1.0, result.score * boostFactor)
|
|
392
|
+
boosted++
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (boosted > 0) {
|
|
397
|
+
// Re-sort after boosting (highest first)
|
|
398
|
+
context.results.sort((a, b) => b.score - a.score)
|
|
399
|
+
log.debug(
|
|
400
|
+
{ provider: provider ?? 'all', boosted, factor: boostFactor, window: recencyWindow ?? '24h' },
|
|
401
|
+
'Hook boost: scores adjusted',
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* `context` — Pre-load context from a provider at session start.
|
|
408
|
+
*
|
|
409
|
+
* Use case: At sessionStart, recall broad context from a provider
|
|
410
|
+
* to prime the session with relevant background knowledge.
|
|
411
|
+
*
|
|
412
|
+
* Fields: provider
|
|
413
|
+
* Events: sessionStart
|
|
414
|
+
*
|
|
415
|
+
* Results are appended to context.results for the caller to use.
|
|
416
|
+
*/
|
|
417
|
+
const executeContext: ActionExecutor = async (hook, context, providers) => {
|
|
418
|
+
const { provider: providerName } = hook
|
|
419
|
+
if (!providerName) {
|
|
420
|
+
log.warn({ action: 'context' }, 'Hook context: requires "provider" — skipping')
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const provider = providers.get(providerName)
|
|
425
|
+
if (!provider) {
|
|
426
|
+
log.warn(
|
|
427
|
+
{ provider: providerName, action: 'context' },
|
|
428
|
+
'Hook context: provider not found — skipping',
|
|
429
|
+
)
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const results = await provider.recall('session context recent activity', { limit: 5 })
|
|
434
|
+
|
|
435
|
+
if (results.length > 0) {
|
|
436
|
+
// Append to context results (don't overwrite existing)
|
|
437
|
+
context.results = [...(context.results ?? []), ...results]
|
|
438
|
+
log.info({ provider: providerName, results: results.length }, 'Hook context: pre-loaded')
|
|
439
|
+
} else {
|
|
440
|
+
log.debug({ provider: providerName }, 'Hook context: no results to pre-load')
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* `summarize` — Generate and save a session summary.
|
|
446
|
+
*
|
|
447
|
+
* Use case: At sessionEnd, build a summary from the session messages
|
|
448
|
+
* and save it to a specific provider.
|
|
449
|
+
*
|
|
450
|
+
* Fields: provider
|
|
451
|
+
* Events: sessionEnd
|
|
452
|
+
*
|
|
453
|
+
* When context.llm is available, uses LLM for intelligent summarization.
|
|
454
|
+
* Falls back to basic topic extraction when LLM is not available or fails.
|
|
455
|
+
*
|
|
456
|
+
* Also updates context.sessionSummary for downstream hooks (e.g., promote).
|
|
457
|
+
*/
|
|
458
|
+
const executeSummarize: ActionExecutor = async (hook, context, providers) => {
|
|
459
|
+
const { provider: providerName } = hook
|
|
460
|
+
|
|
461
|
+
if (!context.messages?.length) {
|
|
462
|
+
log.debug({ action: 'summarize' }, 'Hook summarize: no messages — skipping')
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
let summary = context.sessionSummary
|
|
467
|
+
let usedLLM = false
|
|
468
|
+
|
|
469
|
+
// Try LLM summarization first (if available and no summary exists yet)
|
|
470
|
+
log.debug(
|
|
471
|
+
{ hasLLM: !!context.llm, hasSummary: !!summary, messageCount: context.messages?.length },
|
|
472
|
+
'Hook summarize: checking LLM conditions',
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
if (!summary && context.llm && context.messages.length >= 2) {
|
|
476
|
+
try {
|
|
477
|
+
const transcript = context.messages
|
|
478
|
+
.map((m) => `${m.role}: ${m.content}`)
|
|
479
|
+
.join('\n')
|
|
480
|
+
.slice(0, MAX_TRANSCRIPT_CHARS)
|
|
481
|
+
|
|
482
|
+
log.debug(
|
|
483
|
+
{
|
|
484
|
+
transcriptLength: transcript.length,
|
|
485
|
+
promptLength: SUMMARIZE_PROMPT.length + transcript.length,
|
|
486
|
+
},
|
|
487
|
+
'Hook summarize: calling LLM',
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
const llmSummary = await context.llm(SUMMARIZE_PROMPT + transcript)
|
|
491
|
+
|
|
492
|
+
log.debug(
|
|
493
|
+
{ responseLength: llmSummary?.length, trimmedLength: llmSummary?.trim()?.length },
|
|
494
|
+
'Hook summarize: LLM response received',
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
if (llmSummary?.trim()) {
|
|
498
|
+
summary = llmSummary.trim()
|
|
499
|
+
usedLLM = true
|
|
500
|
+
log.info(
|
|
501
|
+
{ summaryLength: summary.length, messageCount: context.messageCount },
|
|
502
|
+
'Hook summarize: LLM summary generated',
|
|
503
|
+
)
|
|
504
|
+
} else {
|
|
505
|
+
log.warn('Hook summarize: LLM returned empty response')
|
|
506
|
+
}
|
|
507
|
+
} catch (err) {
|
|
508
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
509
|
+
log.warn({ error: errMsg }, 'Hook summarize: LLM failed, falling back to basic')
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Fallback: basic topic summary
|
|
514
|
+
if (!summary) {
|
|
515
|
+
summary =
|
|
516
|
+
`Session with ${context.messageCount} messages` +
|
|
517
|
+
(context.topics?.length ? ` covering: ${context.topics.join(', ')}` : '')
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Ensure sessionDate is set (from context or auto-detect)
|
|
521
|
+
const sessionDate = context.sessionDate ?? new Date().toISOString().split('T')[0]
|
|
522
|
+
context.sessionDate = sessionDate
|
|
523
|
+
|
|
524
|
+
// Save to specific provider if named
|
|
525
|
+
if (providerName) {
|
|
526
|
+
const provider = providers.get(providerName)
|
|
527
|
+
if (!provider) {
|
|
528
|
+
log.warn(
|
|
529
|
+
{ provider: providerName, action: 'summarize' },
|
|
530
|
+
'Hook summarize: provider not found — skipping',
|
|
531
|
+
)
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const fact: Fact = {
|
|
536
|
+
content: summary,
|
|
537
|
+
category: 'session-summary',
|
|
538
|
+
domain: 'session',
|
|
539
|
+
tags: ['summary', usedLLM ? 'llm-generated' : 'auto-generated', `session:${sessionDate}`],
|
|
540
|
+
importance: usedLLM ? 7 : 5,
|
|
541
|
+
}
|
|
542
|
+
if (context.episodeId) (fact as Record<string, unknown>).episodeId = context.episodeId
|
|
543
|
+
;(fact as Record<string, unknown>).sessionDate = sessionDate
|
|
544
|
+
|
|
545
|
+
const result = await provider.save([fact])
|
|
546
|
+
// Track created fact IDs
|
|
547
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
548
|
+
context.createdFactIds = [...(context.createdFactIds ?? []), ...result]
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
log.info(
|
|
552
|
+
{
|
|
553
|
+
provider: providerName,
|
|
554
|
+
summaryLength: summary.length,
|
|
555
|
+
llm: usedLLM,
|
|
556
|
+
episodeId: context.episodeId,
|
|
557
|
+
},
|
|
558
|
+
'Hook summarize: saved',
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Update context for downstream hooks (e.g., promote can use it)
|
|
563
|
+
context.sessionSummary = summary
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* `episode` — Create or find an episode for the current session.
|
|
568
|
+
*
|
|
569
|
+
* Use case: At sessionStart, look up or create an episode in the brain
|
|
570
|
+
* for today's date + channel, and store the episodeId in context.
|
|
571
|
+
*
|
|
572
|
+
* Fields: provider
|
|
573
|
+
* Events: sessionStart
|
|
574
|
+
*
|
|
575
|
+
* Sets context.episodeId and context.sessionDate for downstream hooks.
|
|
576
|
+
*/
|
|
577
|
+
const executeEpisode: ActionExecutor = async (hook, context, providers) => {
|
|
578
|
+
const { provider: providerName } = hook
|
|
579
|
+
if (!providerName) {
|
|
580
|
+
log.warn({ action: 'episode' }, 'Hook episode: requires "provider" — skipping')
|
|
581
|
+
return
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const provider = providers.get(providerName)
|
|
585
|
+
if (!provider) {
|
|
586
|
+
log.warn(
|
|
587
|
+
{ provider: providerName, action: 'episode' },
|
|
588
|
+
'Hook episode: provider not found — skipping',
|
|
589
|
+
)
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Check if provider has getOrCreateEpisode (brain-specific)
|
|
594
|
+
const brain = provider as unknown as {
|
|
595
|
+
getOrCreateEpisode?: (
|
|
596
|
+
date: string,
|
|
597
|
+
channel?: string,
|
|
598
|
+
agentId?: string,
|
|
599
|
+
) => Promise<{ id: string }>
|
|
600
|
+
}
|
|
601
|
+
if (!brain.getOrCreateEpisode) {
|
|
602
|
+
log.warn(
|
|
603
|
+
{ provider: providerName, action: 'episode' },
|
|
604
|
+
'Hook episode: provider does not support episodes — skipping',
|
|
605
|
+
)
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const today = context.sessionDate ?? new Date().toISOString().split('T')[0]
|
|
610
|
+
const channel = context.channel
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
const episode = await brain.getOrCreateEpisode(today, channel)
|
|
614
|
+
context.episodeId = episode.id
|
|
615
|
+
context.sessionDate = today
|
|
616
|
+
context.createdFactIds = []
|
|
617
|
+
log.info({ episodeId: episode.id, sessionDate: today, channel }, 'Hook episode: session linked')
|
|
618
|
+
} catch (err) {
|
|
619
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
620
|
+
log.warn({ error: errMsg }, 'Hook episode: failed to create/find episode — continuing without')
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* `journal` — Generate a personal journal entry (domain:self, category:journal).
|
|
626
|
+
*
|
|
627
|
+
* Use case: At sessionEnd, generate a first-person reflection about
|
|
628
|
+
* what happened in the session. This is the companion's inner voice, not a report.
|
|
629
|
+
*
|
|
630
|
+
* Fields: provider
|
|
631
|
+
* Events: sessionEnd
|
|
632
|
+
*
|
|
633
|
+
* Requires context.llm for LLM generation. Falls back to nothing (journal
|
|
634
|
+
* without LLM is meaningless — better to skip than fake it).
|
|
635
|
+
*/
|
|
636
|
+
const executeJournal: ActionExecutor = async (hook, context, providers) => {
|
|
637
|
+
const { provider: providerName } = hook
|
|
638
|
+
|
|
639
|
+
if (!context.messages?.length || context.messages.length < 2) {
|
|
640
|
+
log.debug({ action: 'journal' }, 'Hook journal: insufficient messages — skipping')
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (!context.llm) {
|
|
645
|
+
log.debug({ action: 'journal' }, 'Hook journal: no LLM available — skipping')
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
const transcript = context.messages
|
|
651
|
+
.map((m) => `${m.role}: ${m.content}`)
|
|
652
|
+
.join('\n')
|
|
653
|
+
.slice(0, MAX_TRANSCRIPT_CHARS)
|
|
654
|
+
|
|
655
|
+
const journal = await context.llm(JOURNAL_PROMPT + transcript)
|
|
656
|
+
|
|
657
|
+
if (!journal?.trim()) {
|
|
658
|
+
log.warn('Hook journal: LLM returned empty response')
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const journalText = journal.trim()
|
|
663
|
+
const sessionDate = context.sessionDate ?? new Date().toISOString().split('T')[0]
|
|
664
|
+
|
|
665
|
+
// Save to provider
|
|
666
|
+
if (providerName) {
|
|
667
|
+
const provider = providers.get(providerName)
|
|
668
|
+
if (!provider) {
|
|
669
|
+
log.warn(
|
|
670
|
+
{ provider: providerName, action: 'journal' },
|
|
671
|
+
'Hook journal: provider not found — skipping save',
|
|
672
|
+
)
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const fact: Fact = {
|
|
677
|
+
content: journalText,
|
|
678
|
+
category: 'journal',
|
|
679
|
+
domain: 'self',
|
|
680
|
+
tags: ['journal', 'llm-generated', `session:${sessionDate}`],
|
|
681
|
+
importance: 7,
|
|
682
|
+
}
|
|
683
|
+
if (context.episodeId) (fact as Record<string, unknown>).episodeId = context.episodeId
|
|
684
|
+
;(fact as Record<string, unknown>).sessionDate = sessionDate
|
|
685
|
+
|
|
686
|
+
const result = await provider.save([fact])
|
|
687
|
+
// Track created fact IDs
|
|
688
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
689
|
+
context.createdFactIds = [...(context.createdFactIds ?? []), ...result]
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
log.info(
|
|
693
|
+
{ provider: providerName, length: journalText.length, episodeId: context.episodeId },
|
|
694
|
+
'Hook journal: saved',
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
} catch (err) {
|
|
698
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
699
|
+
log.warn({ error: errMsg }, 'Hook journal: failed — continuing')
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* `experience` — Capture the emotional tone of a session (domain:self, category:experience).
|
|
705
|
+
*
|
|
706
|
+
* Use case: At sessionEnd, generate 1-2 impressionistic sentences about
|
|
707
|
+
* how the session felt. Color, not content.
|
|
708
|
+
*
|
|
709
|
+
* Fields: provider
|
|
710
|
+
* Events: sessionEnd
|
|
711
|
+
*/
|
|
712
|
+
const executeExperience: ActionExecutor = async (hook, context, providers) => {
|
|
713
|
+
const { provider: providerName } = hook
|
|
714
|
+
|
|
715
|
+
if (!context.messages?.length || context.messages.length < 2) {
|
|
716
|
+
log.debug({ action: 'experience' }, 'Hook experience: insufficient messages — skipping')
|
|
717
|
+
return
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (!context.llm) {
|
|
721
|
+
log.debug({ action: 'experience' }, 'Hook experience: no LLM available — skipping')
|
|
722
|
+
return
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
try {
|
|
726
|
+
const transcript = context.messages
|
|
727
|
+
.map((m) => `${m.role}: ${m.content}`)
|
|
728
|
+
.join('\n')
|
|
729
|
+
.slice(0, MAX_TRANSCRIPT_CHARS)
|
|
730
|
+
|
|
731
|
+
const experience = await context.llm(EXPERIENCE_PROMPT + transcript)
|
|
732
|
+
|
|
733
|
+
if (!experience?.trim()) {
|
|
734
|
+
log.warn('Hook experience: LLM returned empty response')
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const experienceText = experience.trim()
|
|
739
|
+
const sessionDate = context.sessionDate ?? new Date().toISOString().split('T')[0]
|
|
740
|
+
|
|
741
|
+
if (providerName) {
|
|
742
|
+
const provider = providers.get(providerName)
|
|
743
|
+
if (!provider) {
|
|
744
|
+
log.warn(
|
|
745
|
+
{ provider: providerName, action: 'experience' },
|
|
746
|
+
'Hook experience: provider not found — skipping save',
|
|
747
|
+
)
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const fact: Fact = {
|
|
752
|
+
content: experienceText,
|
|
753
|
+
category: 'experience',
|
|
754
|
+
domain: 'self',
|
|
755
|
+
tags: ['experience', 'llm-generated', `session:${sessionDate}`],
|
|
756
|
+
importance: 6,
|
|
757
|
+
}
|
|
758
|
+
if (context.episodeId) (fact as Record<string, unknown>).episodeId = context.episodeId
|
|
759
|
+
;(fact as Record<string, unknown>).sessionDate = sessionDate
|
|
760
|
+
|
|
761
|
+
const result = await provider.save([fact])
|
|
762
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
763
|
+
context.createdFactIds = [...(context.createdFactIds ?? []), ...result]
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
log.info(
|
|
767
|
+
{ provider: providerName, length: experienceText.length, episodeId: context.episodeId },
|
|
768
|
+
'Hook experience: saved',
|
|
769
|
+
)
|
|
770
|
+
}
|
|
771
|
+
} catch (err) {
|
|
772
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
773
|
+
log.warn({ error: errMsg }, 'Hook experience: failed — continuing')
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* `reconcile` — Dedup-aware transfer from session summary to long-term memory.
|
|
779
|
+
* Replaces `promote` with smarter behavior:
|
|
780
|
+
* 1. Parse structured lines from LLM summary (like promote full)
|
|
781
|
+
* 2. Dedup each candidate against existing facts in the target provider
|
|
782
|
+
* 3. Only save facts that are genuinely new
|
|
783
|
+
*
|
|
784
|
+
* Fields: from, to
|
|
785
|
+
* Events: afterSessionEnd
|
|
786
|
+
*/
|
|
787
|
+
const executeReconcile: ActionExecutor = async (hook, context, providers) => {
|
|
788
|
+
const { from, to } = hook
|
|
789
|
+
if (!to) {
|
|
790
|
+
log.warn({ action: 'reconcile' }, 'Hook reconcile: requires "to" — skipping')
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const targetProvider = providers.get(to)
|
|
795
|
+
if (!targetProvider) {
|
|
796
|
+
log.warn({ to, action: 'reconcile' }, 'Hook reconcile: target provider not found — skipping')
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Category mapping from LLM prefix to fact category + importance
|
|
801
|
+
const PREFIX_MAP: Record<string, { category: string; importance: number }> = {
|
|
802
|
+
DECISION: { category: 'decision', importance: 7 },
|
|
803
|
+
INFO: { category: 'observation', importance: 5 },
|
|
804
|
+
PROBLEM: { category: 'learning', importance: 6 },
|
|
805
|
+
PENDING: { category: 'pending', importance: 6 },
|
|
806
|
+
TECHNICAL: { category: 'technical', importance: 5 },
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const sessionDate = context.sessionDate ?? new Date().toISOString().split('T')[0]
|
|
810
|
+
const candidates: Fact[] = []
|
|
811
|
+
const seenContent = new Set<string>()
|
|
812
|
+
|
|
813
|
+
// 1. Parse LLM summary lines into individual facts
|
|
814
|
+
if (context.sessionSummary) {
|
|
815
|
+
const lines = context.sessionSummary.split('\n').filter((l) => l.trim())
|
|
816
|
+
for (const line of lines) {
|
|
817
|
+
const match = line.match(/^\s*(DECISION|INFO|PROBLEM|PENDING|TECHNICAL):\s*(.+)/i)
|
|
818
|
+
if (!match) continue
|
|
819
|
+
|
|
820
|
+
const prefix = match[1].toUpperCase()
|
|
821
|
+
const factContent = match[2].trim()
|
|
822
|
+
if (!factContent || seenContent.has(factContent)) continue
|
|
823
|
+
seenContent.add(factContent)
|
|
824
|
+
|
|
825
|
+
const mapped = PREFIX_MAP[prefix] ?? { category: 'observation', importance: 5 }
|
|
826
|
+
const fact: Fact = {
|
|
827
|
+
content: factContent,
|
|
828
|
+
category: mapped.category,
|
|
829
|
+
domain: mapped.category === 'decision' ? 'infra' : 'session',
|
|
830
|
+
source: from,
|
|
831
|
+
tags: ['reconciled', prefix.toLowerCase(), `session:${sessionDate}`],
|
|
832
|
+
importance: mapped.importance,
|
|
833
|
+
}
|
|
834
|
+
if (context.episodeId) (fact as Record<string, unknown>).episodeId = context.episodeId
|
|
835
|
+
;(fact as Record<string, unknown>).sessionDate = sessionDate
|
|
836
|
+
|
|
837
|
+
candidates.push(fact)
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (candidates.length === 0) {
|
|
842
|
+
log.debug({ action: 'reconcile' }, 'Hook reconcile: no candidates from summary — skipping')
|
|
843
|
+
return
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// 2. Dedup against existing facts for this session date
|
|
847
|
+
// Query target provider for facts from today to check for duplicates
|
|
848
|
+
let existingContents: Set<string> = new Set()
|
|
849
|
+
try {
|
|
850
|
+
const existing = await targetProvider.recall(context.topics?.join(' ') || 'session activity', {
|
|
851
|
+
limit: 30,
|
|
852
|
+
})
|
|
853
|
+
existingContents = new Set(existing.map((r) => r.content.toLowerCase().trim()))
|
|
854
|
+
} catch {
|
|
855
|
+
log.debug(
|
|
856
|
+
{ action: 'reconcile' },
|
|
857
|
+
'Hook reconcile: could not query existing — saving all candidates',
|
|
858
|
+
)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// 3. Filter out candidates that already exist (simple containment check)
|
|
862
|
+
const newFacts = candidates.filter((f) => {
|
|
863
|
+
const normalized = f.content.toLowerCase().trim()
|
|
864
|
+
// Check if any existing fact is very similar (contains or is contained)
|
|
865
|
+
for (const existing of existingContents) {
|
|
866
|
+
if (existing.includes(normalized) || normalized.includes(existing)) {
|
|
867
|
+
return false
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return true
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
// Cap at 10
|
|
874
|
+
const toSave = newFacts.slice(0, 10)
|
|
875
|
+
|
|
876
|
+
if (toSave.length > 0) {
|
|
877
|
+
const result = await targetProvider.save(toSave)
|
|
878
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
879
|
+
context.createdFactIds = [...(context.createdFactIds ?? []), ...result]
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
log.info(
|
|
884
|
+
{
|
|
885
|
+
to,
|
|
886
|
+
candidates: candidates.length,
|
|
887
|
+
deduplicated: candidates.length - newFacts.length,
|
|
888
|
+
saved: toSave.length,
|
|
889
|
+
episodeId: context.episodeId,
|
|
890
|
+
},
|
|
891
|
+
'Hook reconcile: complete',
|
|
892
|
+
)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* `episodeClose` — Update episode with all fact IDs at session close.
|
|
897
|
+
*
|
|
898
|
+
* Use case: After all sessionEnd/afterSessionEnd hooks have run,
|
|
899
|
+
* update the episode with the complete list of factIds.
|
|
900
|
+
*
|
|
901
|
+
* Fields: provider
|
|
902
|
+
* Events: afterSessionEnd
|
|
903
|
+
*/
|
|
904
|
+
const executeEpisodeClose: ActionExecutor = async (hook, context, providers) => {
|
|
905
|
+
const { provider: providerName } = hook
|
|
906
|
+
|
|
907
|
+
if (!context.episodeId) {
|
|
908
|
+
log.debug({ action: 'episodeClose' }, 'Hook episodeClose: no episodeId — skipping')
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (!providerName) {
|
|
913
|
+
log.warn({ action: 'episodeClose' }, 'Hook episodeClose: requires "provider" — skipping')
|
|
914
|
+
return
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const provider = providers.get(providerName)
|
|
918
|
+
if (!provider) {
|
|
919
|
+
log.warn(
|
|
920
|
+
{ provider: providerName, action: 'episodeClose' },
|
|
921
|
+
'Hook episodeClose: provider not found — skipping',
|
|
922
|
+
)
|
|
923
|
+
return
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const brain = provider as unknown as {
|
|
927
|
+
updateEpisode?: (id: string, data: Record<string, unknown>) => Promise<unknown>
|
|
928
|
+
}
|
|
929
|
+
if (!brain.updateEpisode) {
|
|
930
|
+
log.warn(
|
|
931
|
+
{ action: 'episodeClose' },
|
|
932
|
+
'Hook episodeClose: provider does not support updateEpisode — skipping',
|
|
933
|
+
)
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
try {
|
|
938
|
+
const updateData: Record<string, unknown> = {}
|
|
939
|
+
if (context.createdFactIds?.length) updateData.factIds = context.createdFactIds
|
|
940
|
+
if (context.sessionSummary) updateData.summary = context.sessionSummary.slice(0, 500)
|
|
941
|
+
if (context.topics?.length) updateData.topics = context.topics
|
|
942
|
+
updateData.messageCount = context.messageCount
|
|
943
|
+
|
|
944
|
+
await brain.updateEpisode(context.episodeId, updateData)
|
|
945
|
+
log.info(
|
|
946
|
+
{ episodeId: context.episodeId, factCount: context.createdFactIds?.length ?? 0 },
|
|
947
|
+
'Hook episodeClose: episode updated',
|
|
948
|
+
)
|
|
949
|
+
} catch (err) {
|
|
950
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
951
|
+
log.warn({ error: errMsg, episodeId: context.episodeId }, 'Hook episodeClose: failed')
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// ════════════════════════════════════════════════════════════
|
|
956
|
+
// Action registry — extensible via registerAction()
|
|
957
|
+
// ════════════════════════════════════════════════════════════
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* `diaryRecall` — Load diary entries (journals, experiences, summaries) by date at session start.
|
|
961
|
+
*
|
|
962
|
+
* Unlike generic recall (semantic similarity), this loads by date:
|
|
963
|
+
* 1. domain:self facts from today (journals + experiences)
|
|
964
|
+
* 2. session-summaries from today
|
|
965
|
+
* 3. If today is empty → try yesterday
|
|
966
|
+
*
|
|
967
|
+
* Results are prepended to context.results so they always appear in Memory Context,
|
|
968
|
+
* regardless of semantic relevance score.
|
|
969
|
+
*
|
|
970
|
+
* Fields: provider
|
|
971
|
+
* Events: sessionStart
|
|
972
|
+
*/
|
|
973
|
+
const executeDiaryRecall: ActionExecutor = async (hook, context, providers) => {
|
|
974
|
+
const { provider: providerName } = hook
|
|
975
|
+
if (!providerName) {
|
|
976
|
+
log.warn({ action: 'diaryRecall' }, 'Hook diaryRecall: requires "provider" — skipping')
|
|
977
|
+
return
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const provider = providers.get(providerName)
|
|
981
|
+
if (!provider) {
|
|
982
|
+
log.warn(
|
|
983
|
+
{ provider: providerName, action: 'diaryRecall' },
|
|
984
|
+
'Hook diaryRecall: provider not found — skipping',
|
|
985
|
+
)
|
|
986
|
+
return
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Check if provider has queryFacts (brain-specific)
|
|
990
|
+
const brain = provider as unknown as {
|
|
991
|
+
queryFacts?: (filters: Record<string, unknown>) => Promise<
|
|
992
|
+
Array<{
|
|
993
|
+
id: string
|
|
994
|
+
content: string
|
|
995
|
+
domain: string
|
|
996
|
+
category: string
|
|
997
|
+
episodeId?: string
|
|
998
|
+
sessionDate?: string
|
|
999
|
+
importance: number
|
|
1000
|
+
createdAt: string
|
|
1001
|
+
}>
|
|
1002
|
+
>
|
|
1003
|
+
}
|
|
1004
|
+
if (!brain.queryFacts) {
|
|
1005
|
+
log.warn(
|
|
1006
|
+
{ action: 'diaryRecall' },
|
|
1007
|
+
'Hook diaryRecall: provider does not support queryFacts — skipping',
|
|
1008
|
+
)
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const today = context.sessionDate ?? new Date().toISOString().split('T')[0]
|
|
1013
|
+
|
|
1014
|
+
// Helper: get yesterday's date
|
|
1015
|
+
const getYesterday = (date: string): string => {
|
|
1016
|
+
const d = new Date(date + 'T12:00:00Z')
|
|
1017
|
+
d.setDate(d.getDate() - 1)
|
|
1018
|
+
return d.toISOString().split('T')[0]
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
try {
|
|
1022
|
+
// 1. Get domain:self facts from today (journals + experiences)
|
|
1023
|
+
let selfFacts = await brain.queryFacts({ sessionDate: today, domain: 'self', limit: 20 })
|
|
1024
|
+
|
|
1025
|
+
// 2. Get session-summaries from today
|
|
1026
|
+
let summaryFacts = await brain.queryFacts({
|
|
1027
|
+
sessionDate: today,
|
|
1028
|
+
domain: 'session',
|
|
1029
|
+
category: 'session-summary',
|
|
1030
|
+
limit: 10,
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
// 3. If today is empty → try yesterday
|
|
1034
|
+
let usedYesterday = false
|
|
1035
|
+
if (selfFacts.length === 0 && summaryFacts.length === 0) {
|
|
1036
|
+
const yesterday = getYesterday(today)
|
|
1037
|
+
log.debug({ today, yesterday }, 'Hook diaryRecall: today empty, trying yesterday')
|
|
1038
|
+
selfFacts = await brain.queryFacts({ sessionDate: yesterday, domain: 'self', limit: 20 })
|
|
1039
|
+
summaryFacts = await brain.queryFacts({
|
|
1040
|
+
sessionDate: yesterday,
|
|
1041
|
+
domain: 'session',
|
|
1042
|
+
category: 'session-summary',
|
|
1043
|
+
limit: 10,
|
|
1044
|
+
})
|
|
1045
|
+
usedYesterday = selfFacts.length > 0 || summaryFacts.length > 0
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// ── Score gradient by category and day ──
|
|
1049
|
+
// Today: summaries=1.0, journals=0.95, experiences=0.90
|
|
1050
|
+
// Yesterday: summaries=0.85, journals=0.80, experiences=0.75
|
|
1051
|
+
const dayOffset = usedYesterday ? 0.15 : 0
|
|
1052
|
+
|
|
1053
|
+
// ── Cap per category to prevent context bloat ──
|
|
1054
|
+
const MAX_SUMMARIES = 3
|
|
1055
|
+
const MAX_JOURNALS = 4
|
|
1056
|
+
const MAX_EXPERIENCES = 3
|
|
1057
|
+
|
|
1058
|
+
const journals = selfFacts.filter((f) => f.category === 'journal').slice(-MAX_JOURNALS)
|
|
1059
|
+
const experiences = selfFacts.filter((f) => f.category === 'experience').slice(-MAX_EXPERIENCES)
|
|
1060
|
+
const cappedSummaries = summaryFacts.slice(-MAX_SUMMARIES)
|
|
1061
|
+
|
|
1062
|
+
const diaryResults: MemoryResult[] = []
|
|
1063
|
+
|
|
1064
|
+
const makeDiaryResult = (
|
|
1065
|
+
fact: (typeof selfFacts)[0],
|
|
1066
|
+
label: string,
|
|
1067
|
+
baseScore: number,
|
|
1068
|
+
): MemoryResult => {
|
|
1069
|
+
const epTag = fact.episodeId ? ` (ep:${fact.episodeId.slice(0, 8)})` : ''
|
|
1070
|
+
return {
|
|
1071
|
+
content: `[${label}]${epTag} ${fact.content}`,
|
|
1072
|
+
score: Math.max(0.5, baseScore - dayOffset),
|
|
1073
|
+
source: 'brain:diary',
|
|
1074
|
+
metadata: {
|
|
1075
|
+
type: 'fact',
|
|
1076
|
+
id: fact.id,
|
|
1077
|
+
domain: fact.domain,
|
|
1078
|
+
category: fact.category,
|
|
1079
|
+
episodeId: fact.episodeId,
|
|
1080
|
+
sessionDate: fact.sessionDate,
|
|
1081
|
+
},
|
|
1082
|
+
storedAt: new Date(fact.createdAt),
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Order: summaries → journals → experiences (descending importance)
|
|
1087
|
+
for (const fact of cappedSummaries) {
|
|
1088
|
+
diaryResults.push(makeDiaryResult(fact, 'summary', 1.0))
|
|
1089
|
+
}
|
|
1090
|
+
for (const fact of journals) {
|
|
1091
|
+
diaryResults.push(makeDiaryResult(fact, 'journal', 0.95))
|
|
1092
|
+
}
|
|
1093
|
+
for (const fact of experiences) {
|
|
1094
|
+
diaryResults.push(makeDiaryResult(fact, 'experience', 0.9))
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (diaryResults.length > 0) {
|
|
1098
|
+
// Prepend diary results to context.results (before any generic recall)
|
|
1099
|
+
context.results = [...diaryResults, ...(context.results ?? [])]
|
|
1100
|
+
log.info(
|
|
1101
|
+
{
|
|
1102
|
+
provider: providerName,
|
|
1103
|
+
self: selfFacts.length,
|
|
1104
|
+
summaries: summaryFacts.length,
|
|
1105
|
+
date: today,
|
|
1106
|
+
},
|
|
1107
|
+
'Hook diaryRecall: loaded',
|
|
1108
|
+
)
|
|
1109
|
+
} else {
|
|
1110
|
+
log.debug({ provider: providerName, date: today }, 'Hook diaryRecall: no entries found')
|
|
1111
|
+
}
|
|
1112
|
+
} catch (err) {
|
|
1113
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
1114
|
+
log.warn({ error: errMsg }, 'Hook diaryRecall: failed — continuing without')
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const ACTION_REGISTRY = new Map<string, ActionExecutor>([
|
|
1119
|
+
['promote', executePromote],
|
|
1120
|
+
['boost', executeBoost],
|
|
1121
|
+
['context', executeContext],
|
|
1122
|
+
['summarize', executeSummarize],
|
|
1123
|
+
['episode', executeEpisode],
|
|
1124
|
+
['journal', executeJournal],
|
|
1125
|
+
['experience', executeExperience],
|
|
1126
|
+
['reconcile', executeReconcile],
|
|
1127
|
+
['episodeClose', executeEpisodeClose],
|
|
1128
|
+
['diaryRecall', executeDiaryRecall],
|
|
1129
|
+
])
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Register a custom action executor.
|
|
1133
|
+
* Overwrites existing actions with the same name.
|
|
1134
|
+
*/
|
|
1135
|
+
export function registerAction(name: string, executor: ActionExecutor): void {
|
|
1136
|
+
ACTION_REGISTRY.set(name, executor)
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Get a registered action by name (mainly for testing).
|
|
1141
|
+
*/
|
|
1142
|
+
export function getAction(name: string): ActionExecutor | undefined {
|
|
1143
|
+
return ACTION_REGISTRY.get(name)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* List all registered action names.
|
|
1148
|
+
*/
|
|
1149
|
+
export function listActions(): string[] {
|
|
1150
|
+
return [...ACTION_REGISTRY.keys()]
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// ════════════════════════════════════════════════════════════
|
|
1154
|
+
// HookRunner — the main component
|
|
1155
|
+
// ════════════════════════════════════════════════════════════
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* HookRunner — Lifecycle hook evaluation and execution engine.
|
|
1159
|
+
*
|
|
1160
|
+
* Plugs into MemoryOrchestrator to run user-defined hooks at each
|
|
1161
|
+
* lifecycle event (sessionStart, afterRecall, sessionEnd, etc.).
|
|
1162
|
+
*
|
|
1163
|
+
* Thread of execution per event:
|
|
1164
|
+
* 1. Get hooks for event (declared order)
|
|
1165
|
+
* 2. For each hook:
|
|
1166
|
+
* a. Evaluate guard conditions → skip if not met
|
|
1167
|
+
* b. Resolve action from registry → skip if unknown
|
|
1168
|
+
* c. Execute action with mutable context → catch errors
|
|
1169
|
+
* 3. Return (possibly modified) context
|
|
1170
|
+
*/
|
|
1171
|
+
export class HookRunner {
|
|
1172
|
+
private providers: Map<string, MemoryProvider>
|
|
1173
|
+
private hooks: HooksConfig
|
|
1174
|
+
private llm?: LLMFunction
|
|
1175
|
+
|
|
1176
|
+
constructor(
|
|
1177
|
+
hooks: HooksConfig,
|
|
1178
|
+
providers: Map<string, MemoryProvider> | MemoryProvider[],
|
|
1179
|
+
options?: { llm?: LLMFunction },
|
|
1180
|
+
) {
|
|
1181
|
+
this.hooks = hooks
|
|
1182
|
+
this.llm = options?.llm
|
|
1183
|
+
|
|
1184
|
+
if (Array.isArray(providers)) {
|
|
1185
|
+
this.providers = new Map(providers.map((p) => [p.id, p]))
|
|
1186
|
+
} else {
|
|
1187
|
+
this.providers = new Map(providers)
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Set or update the LLM function at runtime.
|
|
1193
|
+
*/
|
|
1194
|
+
setLLM(llm: LLMFunction | undefined): void {
|
|
1195
|
+
this.llm = llm
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Run all hooks for a lifecycle event.
|
|
1200
|
+
*
|
|
1201
|
+
* @param event - The lifecycle event name
|
|
1202
|
+
* @param context - Mutable context that actions can modify
|
|
1203
|
+
* @returns The (possibly modified) context
|
|
1204
|
+
*/
|
|
1205
|
+
async run(event: LifecycleEvent, context: HookContext): Promise<HookContext> {
|
|
1206
|
+
const eventHooks = this.hooks[event]
|
|
1207
|
+
if (!eventHooks?.length) return context
|
|
1208
|
+
|
|
1209
|
+
// Inject LLM function into context if available
|
|
1210
|
+
if (this.llm && !context.llm) {
|
|
1211
|
+
context.llm = this.llm
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
for (const hook of eventHooks) {
|
|
1215
|
+
try {
|
|
1216
|
+
// 1. Evaluate guard
|
|
1217
|
+
const shouldRun = evaluateGuard(hook.when, context, this.providers)
|
|
1218
|
+
if (!shouldRun) {
|
|
1219
|
+
log.debug({ event, action: hook.action, reason: 'guard-not-met' }, 'Hook skipped')
|
|
1220
|
+
continue
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// 2. Resolve action
|
|
1224
|
+
const executor = ACTION_REGISTRY.get(hook.action)
|
|
1225
|
+
if (!executor) {
|
|
1226
|
+
log.warn({ event, action: hook.action }, 'Unknown hook action — skipping')
|
|
1227
|
+
continue
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// 3. Execute (best-effort)
|
|
1231
|
+
await executor(hook, context, this.providers)
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
1234
|
+
log.warn(
|
|
1235
|
+
{ event, action: hook.action, error: errMsg },
|
|
1236
|
+
'Hook execution failed — continuing',
|
|
1237
|
+
)
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
return context
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Check if any hooks are registered for a given event.
|
|
1246
|
+
*/
|
|
1247
|
+
hasHooks(event: LifecycleEvent): boolean {
|
|
1248
|
+
return (this.hooks[event]?.length ?? 0) > 0
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Get the list of events that have at least one hook.
|
|
1253
|
+
*/
|
|
1254
|
+
get activeEvents(): LifecycleEvent[] {
|
|
1255
|
+
return (Object.entries(this.hooks) as Array<[LifecycleEvent, HookEntry[] | undefined]>)
|
|
1256
|
+
.filter(([, hooks]) => hooks && hooks.length > 0)
|
|
1257
|
+
.map(([event]) => event)
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Update the provider map (e.g., when a provider is added/removed at runtime).
|
|
1262
|
+
*/
|
|
1263
|
+
updateProviders(providers: Map<string, MemoryProvider> | MemoryProvider[]): void {
|
|
1264
|
+
if (Array.isArray(providers)) {
|
|
1265
|
+
this.providers = new Map(providers.map((p) => [p.id, p]))
|
|
1266
|
+
} else {
|
|
1267
|
+
this.providers = new Map(providers)
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|