skyloom 1.14.6 → 1.15.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.
Files changed (157) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/publish.yml +74 -0
  3. package/CONVERSION_PLAN.md +191 -191
  4. package/README.md +523 -220
  5. package/config/default.yaml +46 -43
  6. package/config/models.yaml +928 -155
  7. package/config/providers.yaml +109 -6
  8. package/dist/agents/snow.d.ts +2 -0
  9. package/dist/agents/snow.d.ts.map +1 -1
  10. package/dist/agents/snow.js +36 -5
  11. package/dist/agents/snow.js.map +1 -1
  12. package/dist/cli/loom_chat.d.ts.map +1 -1
  13. package/dist/cli/loom_chat.js +207 -1
  14. package/dist/cli/loom_chat.js.map +1 -1
  15. package/dist/cli/main.js +190 -40
  16. package/dist/cli/main.js.map +1 -1
  17. package/dist/cli/tui.d.ts.map +1 -1
  18. package/dist/cli/tui.js +6 -31
  19. package/dist/cli/tui.js.map +1 -1
  20. package/dist/core/agent.d.ts +6 -4
  21. package/dist/core/agent.d.ts.map +1 -1
  22. package/dist/core/agent.js +61 -20
  23. package/dist/core/agent.js.map +1 -1
  24. package/dist/core/catalog.d.ts.map +1 -1
  25. package/dist/core/catalog.js +30 -9
  26. package/dist/core/catalog.js.map +1 -1
  27. package/dist/core/commands.d.ts +110 -0
  28. package/dist/core/commands.d.ts.map +1 -0
  29. package/dist/core/commands.js +633 -0
  30. package/dist/core/commands.js.map +1 -0
  31. package/dist/core/concurrency.d.ts +38 -0
  32. package/dist/core/concurrency.d.ts.map +1 -0
  33. package/dist/core/concurrency.js +65 -0
  34. package/dist/core/concurrency.js.map +1 -0
  35. package/dist/core/factory.js +16 -16
  36. package/dist/core/file_checkpoint.d.ts +9 -0
  37. package/dist/core/file_checkpoint.d.ts.map +1 -1
  38. package/dist/core/file_checkpoint.js +33 -1
  39. package/dist/core/file_checkpoint.js.map +1 -1
  40. package/dist/core/llm.d.ts.map +1 -1
  41. package/dist/core/llm.js +66 -13
  42. package/dist/core/llm.js.map +1 -1
  43. package/dist/core/memory.js +51 -51
  44. package/dist/core/schemas.d.ts +16 -0
  45. package/dist/core/schemas.d.ts.map +1 -1
  46. package/dist/core/schemas.js +32 -0
  47. package/dist/core/schemas.js.map +1 -1
  48. package/dist/core/security.d.ts.map +1 -1
  49. package/dist/core/security.js +27 -0
  50. package/dist/core/security.js.map +1 -1
  51. package/dist/core/skymd.js +14 -14
  52. package/dist/core/trace.d.ts +105 -0
  53. package/dist/core/trace.d.ts.map +1 -0
  54. package/dist/core/trace.js +213 -0
  55. package/dist/core/trace.js.map +1 -0
  56. package/dist/tools/builtin.d.ts +2 -6
  57. package/dist/tools/builtin.d.ts.map +1 -1
  58. package/dist/tools/builtin.js +180 -125
  59. package/dist/tools/builtin.js.map +1 -1
  60. package/dist/tools/extra.d.ts +13 -0
  61. package/dist/tools/extra.d.ts.map +1 -0
  62. package/dist/tools/extra.js +827 -0
  63. package/dist/tools/extra.js.map +1 -0
  64. package/dist/tools/guards.d.ts +12 -0
  65. package/dist/tools/guards.d.ts.map +1 -0
  66. package/dist/tools/guards.js +143 -0
  67. package/dist/tools/guards.js.map +1 -0
  68. package/dist/tools/model_tool.d.ts.map +1 -1
  69. package/dist/tools/model_tool.js +24 -4
  70. package/dist/tools/model_tool.js.map +1 -1
  71. package/dist/web/markdown.d.ts +32 -0
  72. package/dist/web/markdown.d.ts.map +1 -0
  73. package/dist/web/markdown.js +202 -0
  74. package/dist/web/markdown.js.map +1 -0
  75. package/dist/web/server.d.ts +4 -0
  76. package/dist/web/server.d.ts.map +1 -1
  77. package/dist/web/server.js +14 -582
  78. package/dist/web/server.js.map +1 -1
  79. package/dist/web/ui.d.ts +31 -0
  80. package/dist/web/ui.d.ts.map +1 -0
  81. package/dist/web/ui.js +1009 -0
  82. package/dist/web/ui.js.map +1 -0
  83. package/docs/AESTHETIC_DESIGN.md +152 -152
  84. package/docs/OPTIMIZATION_PLAN.md +178 -178
  85. package/package.json +68 -68
  86. package/src/agents/snow.ts +38 -5
  87. package/src/cli/commands_md.ts +112 -112
  88. package/src/cli/input_macros.ts +83 -83
  89. package/src/cli/loom.ts +1041 -1041
  90. package/src/cli/loom_chat.ts +772 -603
  91. package/src/cli/main.ts +853 -723
  92. package/src/cli/tui.ts +264 -289
  93. package/src/core/agent/guard.ts +133 -133
  94. package/src/core/agent/task.ts +100 -100
  95. package/src/core/agent.ts +1630 -1590
  96. package/src/core/agent_helpers.ts +500 -500
  97. package/src/core/bus.ts +221 -221
  98. package/src/core/cache.ts +153 -153
  99. package/src/core/catalog.ts +199 -178
  100. package/src/core/circuit_breaker.ts +119 -119
  101. package/src/core/commands.ts +704 -0
  102. package/src/core/concurrency.ts +73 -0
  103. package/src/core/config.ts +365 -365
  104. package/src/core/constants.ts +95 -95
  105. package/src/core/factory.ts +656 -656
  106. package/src/core/file_checkpoint.ts +163 -136
  107. package/src/core/hooks.ts +126 -126
  108. package/src/core/llm.ts +972 -915
  109. package/src/core/logger.ts +143 -143
  110. package/src/core/mcp.ts +1001 -1001
  111. package/src/core/memory.ts +1201 -1201
  112. package/src/core/middleware.ts +350 -350
  113. package/src/core/model_config.ts +159 -159
  114. package/src/core/pipelines.ts +424 -424
  115. package/src/core/schemas.ts +319 -282
  116. package/src/core/security.ts +27 -0
  117. package/src/core/semantic.ts +211 -211
  118. package/src/core/skill.ts +384 -384
  119. package/src/core/skymd.ts +143 -143
  120. package/src/core/theme.ts +65 -65
  121. package/src/core/tool.ts +457 -457
  122. package/src/core/trace.ts +236 -0
  123. package/src/core/verify.ts +71 -71
  124. package/src/plugins/loader.ts +91 -91
  125. package/src/skills/loader.ts +75 -75
  126. package/src/tools/builtin.ts +571 -493
  127. package/src/tools/computer.ts +279 -279
  128. package/src/tools/extra.ts +662 -0
  129. package/src/tools/guards.ts +82 -0
  130. package/src/tools/model_tool.ts +93 -74
  131. package/src/tools/todo.ts +76 -76
  132. package/src/web/markdown.ts +193 -0
  133. package/src/web/server.ts +117 -693
  134. package/src/web/ui.ts +949 -0
  135. package/tests/agent.test.ts +211 -159
  136. package/tests/agent_helpers.test.ts +48 -48
  137. package/tests/catalog.test.ts +86 -86
  138. package/tests/checkpoint_commands.test.ts +124 -124
  139. package/tests/claude_compat.test.ts +110 -110
  140. package/tests/commands.test.ts +103 -0
  141. package/tests/concurrency.test.ts +102 -0
  142. package/tests/config.test.ts +41 -41
  143. package/tests/extra_tools.test.ts +212 -0
  144. package/tests/fence_plugin.test.ts +52 -52
  145. package/tests/guard.test.ts +75 -75
  146. package/tests/loom.test.ts +337 -337
  147. package/tests/memory.test.ts +170 -170
  148. package/tests/model_config.test.ts +109 -109
  149. package/tests/skymd.test.ts +146 -146
  150. package/tests/ssrf.test.ts +38 -38
  151. package/tests/structured_retry.test.ts +87 -0
  152. package/tests/task.test.ts +60 -60
  153. package/tests/todo_toolstats.test.ts +94 -94
  154. package/tests/trace.test.ts +128 -0
  155. package/tests/tui.test.ts +67 -67
  156. package/tests/web.test.ts +169 -0
  157. package/tsconfig.json +38 -38
@@ -1,1201 +1,1201 @@
1
- /**
2
- * Memory system: short-term context, long-term persistence, working memory.
3
- *
4
- * Three-layer memory for each agent with SQLite persistence.
5
- * - Short-term: conversation context (persisted to SQLite)
6
- * - Working: in-memory task-scoped state
7
- * - Long-term: persistent key-value storage with search
8
- */
9
-
10
- import initSqlJs, { Database as SqlJsDatabase, SqlJsStatic } from 'sql.js';
11
- import * as fs from 'fs';
12
- import * as path from 'path';
13
- import * as os from 'os';
14
-
15
- import type { MemoryConfig } from './config';
16
- import { getLogger } from './logger';
17
- import { getScorer } from './semantic';
18
-
19
- const logger = getLogger('memory');
20
-
21
- // Token extraction patterns for fact-recall queries
22
- const ASCII_TOKEN_RE = /[A-Za-z][A-Za-z0-9_+-]+/g;
23
- const CJK_RUN_RE = /[一-鿿]+/g;
24
-
25
- /**
26
- * Message interface representing a conversation message.
27
- */
28
- export interface Message {
29
- role: string;
30
- content: string;
31
- name?: string | null;
32
- toolCallId?: string | null;
33
- toolCalls?: Record<string, any>[] | null;
34
- reasoningContent?: string | null;
35
- }
36
-
37
- /**
38
- * Simple inline Mutex implementation (replaces async-lock dependency).
39
- */
40
- class SimpleMutex {
41
- private _locked = false;
42
- private _queue: Array<() => void> = [];
43
-
44
- async lock<T>(fn: () => Promise<T>): Promise<T> {
45
- await new Promise<void>((resolve) => {
46
- if (!this._locked) {
47
- this._locked = true;
48
- resolve();
49
- } else {
50
- this._queue.push(resolve);
51
- }
52
- });
53
- try {
54
- return await fn();
55
- } finally {
56
- if (this._queue.length > 0) {
57
- const next = this._queue.shift()!;
58
- next();
59
- } else {
60
- this._locked = false;
61
- }
62
- }
63
- }
64
- }
65
-
66
- /**
67
- * Three-layer memory system for agents.
68
- */
69
- export class Memory {
70
- private config: MemoryConfig;
71
- private agentName: string;
72
- public shortTerm: Message[] = [];
73
- public working: Record<string, any> = {};
74
-
75
- private dbPath: string;
76
- private db: SqlJsDatabase | null = null;
77
- private SQL: SqlJsStatic | null = null;
78
- private loaded = false;
79
- private pendingPersists: Set<Promise<void>> = new Set();
80
- private activeSession: string | null = null;
81
- private saveTimer: ReturnType<typeof setTimeout> | null = null;
82
-
83
- // short_term is mutated from both main chat loop and handlers
84
- // All mutations go through a short critical section
85
- private shortTermLock = new SimpleMutex();
86
-
87
- constructor(config: MemoryConfig, agentName: string) {
88
- this.config = config;
89
- this.agentName = agentName;
90
-
91
- const base = expandUserPath(config.dbPath);
92
- this.dbPath = path.join(path.dirname(base), `${agentName}.db`);
93
- }
94
-
95
- /**
96
- * Initialize the database and load persistent data.
97
- */
98
- async initDb(): Promise<void> {
99
- if (this.db !== null) {
100
- return;
101
- }
102
-
103
- // Initialize sql.js
104
- this.SQL = await initSqlJs();
105
-
106
- // Create directory if it doesn't exist
107
- const dbDir = path.dirname(this.dbPath);
108
- if (!fs.existsSync(dbDir)) {
109
- fs.mkdirSync(dbDir, { recursive: true });
110
- }
111
-
112
- // Load existing database or create new
113
- let dbBuffer: Buffer | null = null;
114
- if (fs.existsSync(this.dbPath)) {
115
- try {
116
- dbBuffer = fs.readFileSync(this.dbPath);
117
- } catch { /* read failed, start fresh */ }
118
- }
119
-
120
- this.db = new this.SQL.Database(dbBuffer || undefined);
121
- this.db.run('PRAGMA journal_mode = MEMORY');
122
- this.db.run('PRAGMA busy_timeout = 100');
123
-
124
- // Create tables
125
- this.db.run(`
126
- CREATE TABLE IF NOT EXISTS memories (
127
- id INTEGER PRIMARY KEY AUTOINCREMENT,
128
- agent TEXT NOT NULL,
129
- key TEXT NOT NULL,
130
- value TEXT NOT NULL,
131
- category TEXT DEFAULT 'general',
132
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
133
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
134
- )
135
- `);
136
-
137
- // Migrate existing DBs
138
- try {
139
- this.db.run("ALTER TABLE memories ADD COLUMN category TEXT DEFAULT 'general'");
140
- } catch {
141
- // Column already exists
142
- }
143
-
144
- try {
145
- this.db.run('ALTER TABLE memories ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP');
146
- } catch {
147
- // Column already exists
148
- }
149
-
150
- // Ensure unique index
151
- try {
152
- this.db.run('DROP INDEX IF EXISTS idx_agent_key');
153
- } catch {
154
- // Ignore
155
- }
156
-
157
- this.db.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_key ON memories(agent, key)');
158
- this.db.run('CREATE INDEX IF NOT EXISTS idx_agent_category ON memories(agent, category)');
159
-
160
- this.db.run(`
161
- CREATE TABLE IF NOT EXISTS messages (
162
- id INTEGER PRIMARY KEY AUTOINCREMENT,
163
- agent TEXT NOT NULL,
164
- role TEXT NOT NULL,
165
- content TEXT NOT NULL,
166
- name TEXT,
167
- tool_call_id TEXT,
168
- tool_calls TEXT,
169
- reasoning_content TEXT,
170
- session_id TEXT,
171
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
172
- )
173
- `);
174
-
175
- this.db.run('CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(agent, created_at)');
176
-
177
- try {
178
- this.db.run('ALTER TABLE messages ADD COLUMN tool_calls TEXT');
179
- } catch {
180
- // Column already exists
181
- }
182
-
183
- try {
184
- this.db.run('ALTER TABLE messages ADD COLUMN reasoning_content TEXT');
185
- } catch {
186
- // Column already exists
187
- }
188
-
189
- try {
190
- this.db.run('ALTER TABLE messages ADD COLUMN session_id TEXT');
191
- } catch {
192
- // Column already exists
193
- }
194
-
195
- this.db.run(`
196
- CREATE TABLE IF NOT EXISTS sessions (
197
- id TEXT PRIMARY KEY,
198
- agent TEXT NOT NULL,
199
- name TEXT,
200
- preview TEXT DEFAULT '',
201
- message_count INTEGER DEFAULT 0,
202
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
203
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
204
- )
205
- `);
206
-
207
- this.db.run('CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent, updated_at DESC)');
208
-
209
- this.db.run(`
210
- CREATE TABLE IF NOT EXISTS working_data (
211
- agent TEXT NOT NULL,
212
- key TEXT NOT NULL,
213
- value TEXT NOT NULL,
214
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
215
- PRIMARY KEY (agent, key)
216
- )
217
- `);
218
-
219
- this.loadShortTerm();
220
- this.loadWorking();
221
- }
222
-
223
- /**
224
- * Persist the database to disk.
225
- */
226
- private persistDb(): void {
227
- if (!this.db) return;
228
- try {
229
- const data = this.db.export();
230
- fs.writeFileSync(this.dbPath, Buffer.from(data));
231
- } catch (err) {
232
- logger.warn('persist_db_failed', { path: this.dbPath, error: String(err) });
233
- }
234
- }
235
-
236
- /**
237
- * Debounced save to disk. sql.js is in-memory, so without this the database
238
- * file is never written and sessions / long-term memory would not survive a
239
- * restart. Coalesces bursts of writes; the timer is unref'd so it never keeps
240
- * the process alive (close() does the final synchronous save).
241
- */
242
- private scheduleSave(): void {
243
- if (!this.db || this.saveTimer) return;
244
- this.saveTimer = setTimeout(() => { this.saveTimer = null; this.persistDb(); }, 300);
245
- if (typeof (this.saveTimer as any).unref === 'function') (this.saveTimer as any).unref();
246
- }
247
-
248
- /**
249
- * Execute a SELECT query and return array of row objects.
250
- */
251
- private dbAll(sql: string, params?: any[]): any[] {
252
- if (!this.db) return [];
253
- try {
254
- const stmt = this.db.prepare(sql);
255
- if (params) stmt.bind(params);
256
- const rows: any[] = [];
257
- while (stmt.step()) {
258
- rows.push(stmt.getAsObject());
259
- }
260
- stmt.free();
261
- return rows;
262
- } catch {
263
- return [];
264
- }
265
- }
266
-
267
- /**
268
- * Execute a SELECT query and return first row object.
269
- */
270
- private dbGet(sql: string, params?: any[]): any | null {
271
- if (!this.db) return null;
272
- try {
273
- const stmt = this.db.prepare(sql);
274
- if (params) stmt.bind(params);
275
- let row: any = null;
276
- if (stmt.step()) {
277
- row = stmt.getAsObject();
278
- }
279
- stmt.free();
280
- return row;
281
- } catch {
282
- return null;
283
- }
284
- }
285
-
286
- /**
287
- * Execute a statement and return this for chaining.
288
- */
289
- private dbRun(sql: string, params?: any[]): void {
290
- if (!this.db) return;
291
- try {
292
- // Sql.js rejects `undefined` in bind arrays; normalize to `null`
293
- const safe = params ? params.map((v) => v === undefined ? null : v) : undefined;
294
- this.db.run(sql, safe);
295
- this.scheduleSave(); // every write goes through here — persist (debounced)
296
- } catch (err) {
297
- logger.warn('db_run_failed', { sql: sql.slice(0, 80), error: String(err) });
298
- }
299
- }
300
-
301
- /**
302
- * Load short-term memory from database.
303
- */
304
- private async loadShortTerm(): Promise<void> {
305
- if (!this.db || this.loaded) {
306
- return;
307
- }
308
-
309
- let rows: any[] = [];
310
-
311
- if (this.activeSession) {
312
- rows = this.dbAll(
313
- `SELECT role, content, name, tool_call_id, tool_calls, reasoning_content, created_at
314
- FROM messages
315
- WHERE agent = ? AND session_id = ?
316
- ORDER BY id DESC LIMIT ?`,
317
- [this.agentName, this.activeSession, this.config.shortTermLimit]
318
- );
319
- } else {
320
- this.loaded = true;
321
- this.pruneDanglingToolCalls();
322
- return;
323
- }
324
-
325
- // Truncate at timestamp gap
326
- const gapSeconds = parseFloat(process.env.WA_RESUME_GAP_SECONDS || '14400'); // 4h default
327
- rows = Memory.truncateAtTimestampGap(rows, gapSeconds);
328
-
329
- for (const row of rows.reverse()) {
330
- let toolCalls: Record<string, any>[] | null = null;
331
- if (row.tool_calls) {
332
- try {
333
- toolCalls = JSON.parse(row.tool_calls);
334
- } catch {
335
- // Invalid JSON, skip
336
- }
337
- }
338
-
339
- this.shortTerm.push({
340
- role: row.role,
341
- content: row.content,
342
- name: row.name,
343
- toolCallId: row.tool_call_id,
344
- toolCalls,
345
- reasoningContent: row.reasoning_content,
346
- });
347
- }
348
-
349
- this.loaded = true;
350
- this.pruneDanglingToolCalls();
351
- }
352
-
353
- /**
354
- * Load working memory from database.
355
- */
356
- private async loadWorking(): Promise<void> {
357
- if (!this.db) {
358
- return;
359
- }
360
-
361
- const rows = this.dbAll(
362
- 'SELECT key, value FROM working_data WHERE agent = ?',
363
- [this.agentName]
364
- );
365
-
366
- for (const row of rows) {
367
- try {
368
- this.working[row.key] = JSON.parse(row.value);
369
- } catch {
370
- // Skip invalid JSON
371
- }
372
- }
373
- }
374
-
375
- /**
376
- * Keep only the contiguous tail of rows where consecutive timestamps
377
- * are within gap_seconds of each other.
378
- */
379
- private static truncateAtTimestampGap(rows: any[], gapSeconds: number): any[] {
380
- if (rows.length === 0 || gapSeconds <= 0) {
381
- return rows;
382
- }
383
-
384
- const parseTs = (raw: any): Date | null => {
385
- if (!raw) return null;
386
- if (raw instanceof Date) return raw;
387
- try {
388
- return new Date(raw);
389
- } catch {
390
- return null;
391
- }
392
- };
393
-
394
- const keep = [rows[0]];
395
- let prevTs = parseTs(rows[0].created_at);
396
-
397
- for (let i = 1; i < rows.length; i++) {
398
- const curTs = parseTs(rows[i].created_at);
399
- if (prevTs && curTs) {
400
- const delta = Math.abs((prevTs.getTime() - curTs.getTime()) / 1000);
401
- if (delta > gapSeconds) {
402
- break;
403
- }
404
- }
405
- keep.push(rows[i]);
406
- if (curTs) {
407
- prevTs = curTs;
408
- }
409
- }
410
-
411
- return keep;
412
- }
413
-
414
- /**
415
- * Remove orphaned tool_calls/tool message pairs from short-term memory.
416
- */
417
- private pruneDanglingToolCalls(): void {
418
- if (this.shortTerm.length === 0) {
419
- return;
420
- }
421
-
422
- const n = this.shortTerm.length;
423
- const remove = new Array(n).fill(false);
424
-
425
- // Pass 1: position-aware matching
426
- const waiting: Record<string, number[]> = {};
427
-
428
- for (let i = 0; i < this.shortTerm.length; i++) {
429
- const msg = this.shortTerm[i];
430
- if (msg.role === 'assistant' && msg.toolCalls) {
431
- for (const tc of msg.toolCalls) {
432
- const tid = tc.id;
433
- if (tid) {
434
- if (!waiting[tid]) waiting[tid] = [];
435
- waiting[tid].push(i);
436
- }
437
- }
438
- } else if (msg.role === 'tool' && msg.toolCallId) {
439
- const tid = msg.toolCallId;
440
- if (waiting[tid] && waiting[tid].length > 0) {
441
- waiting[tid].pop();
442
- } else {
443
- remove[i] = true;
444
- }
445
- }
446
- }
447
-
448
- // Any assistant indices still in waiting stacks are orphaned
449
- for (const indices of Object.values(waiting)) {
450
- for (const i of indices) {
451
- remove[i] = true;
452
- }
453
- }
454
-
455
- if (!remove.some(r => r)) {
456
- return;
457
- }
458
-
459
- const kept = this.shortTerm.filter((_, i) => !remove[i]);
460
-
461
- // Pass 2: remove tool messages with no preceding assistant
462
- const seenTcIds = new Set<string>();
463
- const sanitized: Message[] = [];
464
-
465
- for (const msg of kept) {
466
- if (msg.role === 'assistant' && msg.toolCalls) {
467
- for (const tc of msg.toolCalls) {
468
- const tid = tc.id;
469
- if (tid) {
470
- seenTcIds.add(tid);
471
- }
472
- }
473
- } else if (msg.role === 'tool' && msg.toolCallId && !seenTcIds.has(msg.toolCallId)) {
474
- continue;
475
- }
476
- sanitized.push(msg);
477
- }
478
-
479
- this.shortTerm = sanitized;
480
- }
481
-
482
- /**
483
- * Public wrapper around pruneDanglingToolCalls.
484
- */
485
- public pruneToolMessages(): void {
486
- this.pruneDanglingToolCalls();
487
- }
488
-
489
- /**
490
- * Flush pending operations and close database.
491
- */
492
- async close(): Promise<void> {
493
- if (this.db) {
494
- if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; }
495
- await this.flushPending();
496
- this.persistDb(); // final synchronous save to disk
497
- this.db.close();
498
- }
499
- }
500
-
501
- /**
502
- * Wait for all pending persist operations to complete.
503
- */
504
- private async flushPending(): Promise<void> {
505
- if (this.pendingPersists.size > 0) {
506
- const results = await Promise.allSettled(Array.from(this.pendingPersists));
507
- for (const result of results) {
508
- if (result.status === 'rejected') {
509
- logger.warn('flush_persist_failed', { agent: this.agentName, error: String(result.reason) });
510
- }
511
- }
512
- this.pendingPersists.clear();
513
- }
514
- }
515
-
516
- // -- Short-term memory --
517
-
518
- /**
519
- * Add a message to short-term memory.
520
- */
521
- public addMessage(role: string, content: string, kwargs?: Record<string, any>): void {
522
- const msg: Message = {
523
- role,
524
- content,
525
- name: kwargs?.name,
526
- toolCallId: kwargs?.toolCallId,
527
- toolCalls: kwargs?.toolCalls,
528
- reasoningContent: kwargs?.reasoningContent,
529
- };
530
-
531
- const ephemeral = kwargs?.ephemeral ?? false;
532
-
533
- // Push synchronously so getMessages()/shortTerm reflect the message in the
534
- // same tick — callers (chatImpl/chatStreamImpl) read it immediately after.
535
- // (Single-threaded JS makes the push atomic; only the array-rewriting prune
536
- // needs the mutex.) Previously the push lived inside the async lock body,
537
- // so a fresh session's first user message was missing from the first LLM
538
- // request and messagesWithRecall() crashed on undefined.content.
539
- this.shortTerm.push(msg);
540
-
541
- if (this.shortTerm.length > this.config.shortTermLimit) {
542
- this.shortTermLock.lock(async () => {
543
- if (this.shortTerm.length > this.config.shortTermLimit) {
544
- const systemMsgs = this.shortTerm.filter(m => m.role === 'system');
545
- const otherMsgs = this.shortTerm.filter(m => m.role !== 'system');
546
- const keep = Math.max(0, this.config.shortTermLimit - systemMsgs.length);
547
- this.shortTerm = systemMsgs.concat(keep > 0 ? otherMsgs.slice(-keep) : []);
548
- this.pruneDanglingToolCalls();
549
- }
550
- });
551
- }
552
-
553
- // Persist message if not ephemeral
554
- if (this.db && role !== 'system' && !ephemeral) {
555
- const toolCallsJson = msg.toolCalls ? JSON.stringify(msg.toolCalls) : null;
556
- const sessionId = this.activeSession;
557
-
558
- const promise = this.persistMessage(
559
- role,
560
- content,
561
- msg.name || null,
562
- msg.toolCallId || null,
563
- toolCallsJson,
564
- msg.reasoningContent || null,
565
- sessionId
566
- );
567
-
568
- this.pendingPersists.add(promise);
569
- promise.then(() => {
570
- this.pendingPersists.delete(promise);
571
- }).catch(() => {
572
- this.pendingPersists.delete(promise);
573
- });
574
- }
575
- }
576
-
577
- /**
578
- * Persist a message to database.
579
- */
580
- private async persistMessage(
581
- role: string,
582
- content: string,
583
- name: string | null,
584
- toolCallId: string | null,
585
- toolCalls: string | null = null,
586
- reasoningContent: string | null = null,
587
- sessionId: string | null = null
588
- ): Promise<void> {
589
- if (!this.db) {
590
- return;
591
- }
592
-
593
- try {
594
- this.dbRun(
595
- `INSERT INTO messages (agent, role, content, name, tool_call_id, tool_calls, reasoning_content, session_id)
596
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
597
- [this.agentName, role, content, name, toolCallId, toolCalls, reasoningContent, sessionId]
598
- );
599
-
600
- if (sessionId) {
601
- this.dbRun(
602
- 'UPDATE sessions SET message_count = message_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
603
- [sessionId]
604
- );
605
- }
606
-
607
- // Auto-prune old messages
608
- const maxPersisted = this.config.maxPersistedMessages || 1000;
609
- if (sessionId && maxPersisted > 0) {
610
- const row = this.dbGet(
611
- 'SELECT COUNT(*) as count FROM messages WHERE agent = ? AND session_id = ? AND role != ?',
612
- [this.agentName, sessionId, 'system']
613
- );
614
- if (row && row.count > maxPersisted) {
615
- const excess = row.count - maxPersisted;
616
- this.dbRun(
617
- `DELETE FROM messages WHERE id IN (
618
- SELECT id FROM messages WHERE agent = ? AND session_id = ? AND role != ?
619
- ORDER BY id ASC LIMIT ?
620
- )`,
621
- [this.agentName, sessionId, 'system', excess]
622
- );
623
- }
624
- }
625
- } catch (err) {
626
- logger.warn('persist_message_failed', { agent: this.agentName, error: String(err) });
627
- }
628
- }
629
-
630
- /**
631
- * Get messages as a list of dicts compatible with LLM API.
632
- */
633
- public getMessages(): Record<string, any>[] {
634
- this.pruneDanglingToolCalls();
635
- const msgs: Record<string, any>[] = [];
636
-
637
- for (const m of this.shortTerm) {
638
- const d: Record<string, any> = { role: m.role, content: m.content };
639
- if (m.name) d.name = m.name;
640
- if (m.toolCallId) d.tool_call_id = m.toolCallId;
641
- if (m.toolCalls) d.tool_calls = m.toolCalls;
642
- if (m.reasoningContent) d.reasoning_content = m.reasoningContent;
643
- msgs.push(d);
644
- }
645
-
646
- return msgs;
647
- }
648
-
649
- /**
650
- * Return stats about current memory usage.
651
- */
652
- public getContextWindowUsage(): Record<string, number> {
653
- let totalChars = 0;
654
- let cjk = 0;
655
-
656
- for (const m of this.shortTerm) {
657
- // Count the tool-call payload too: a single tool call with large JSON args
658
- // occupies real context, and ignoring it made tool-heavy turns under-count
659
- // and auto-compaction fire too late.
660
- let text = m.content || '';
661
- if (m.toolCalls) {
662
- try { text += JSON.stringify(m.toolCalls); } catch { /* unserializable — skip */ }
663
- }
664
- totalChars += text.length;
665
- for (const c of text) {
666
- const code = c.charCodeAt(0);
667
- if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3000 && code <= 0x303f)) {
668
- cjk++;
669
- }
670
- }
671
- }
672
-
673
- const other = totalChars - cjk;
674
- return {
675
- messageCount: this.shortTerm.length,
676
- totalChars,
677
- estimatedTokens: Math.max(1, cjk * 2 + Math.floor(other / 4)),
678
- limit: this.config.shortTermLimit,
679
- };
680
- }
681
-
682
- /**
683
- * Clear in-memory short-term memory.
684
- */
685
- async clearShortTerm(): Promise<void> {
686
- const systemMsgs = this.shortTerm.filter(m => m.role === 'system');
687
- this.shortTerm = systemMsgs;
688
-
689
- if (!this.db) {
690
- return;
691
- }
692
-
693
- if (this.activeSession !== null) {
694
- this.dbRun(
695
- 'DELETE FROM messages WHERE agent = ? AND role != ? AND session_id = ?',
696
- [this.agentName, 'system', this.activeSession]
697
- );
698
-
699
- this.dbRun(
700
- 'UPDATE sessions SET message_count = 0 WHERE id = ?',
701
- [this.activeSession]
702
- );
703
- } else {
704
- this.dbRun(
705
- 'DELETE FROM messages WHERE agent = ? AND role != ? AND session_id IS NULL',
706
- [this.agentName, 'system']
707
- );
708
- }
709
- }
710
-
711
- // -- Working memory --
712
-
713
- /**
714
- * Set a working memory value.
715
- */
716
- public setWorking(key: string, value: any): void {
717
- this.working[key] = value;
718
- this.schedulePersistWorking();
719
- }
720
-
721
- /**
722
- * Get a working memory value.
723
- */
724
- public getWorking(key: string, defaultValue: any = null): any {
725
- return this.working[key] ?? defaultValue;
726
- }
727
-
728
- /**
729
- * Clear all working memory.
730
- */
731
- public clearWorking(): void {
732
- this.working = {};
733
- this.schedulePersistWorking();
734
- }
735
-
736
- private schedulePersistWorking(): void {
737
- if (!this.db) {
738
- return;
739
- }
740
-
741
- const promise = this.persistWorking();
742
- this.pendingPersists.add(promise);
743
- promise.then(() => {
744
- this.pendingPersists.delete(promise);
745
- }).catch(() => {
746
- this.pendingPersists.delete(promise);
747
- });
748
- }
749
-
750
- private async persistWorking(): Promise<void> {
751
- if (!this.db) {
752
- return;
753
- }
754
-
755
- try {
756
- this.dbRun(
757
- 'DELETE FROM working_data WHERE agent = ?',
758
- [this.agentName]
759
- );
760
-
761
- for (const [key, value] of Object.entries(this.working)) {
762
- this.dbRun(
763
- 'INSERT INTO working_data (agent, key, value) VALUES (?, ?, ?)',
764
- [this.agentName, key, JSON.stringify(value)]
765
- );
766
- }
767
- } catch (err) {
768
- logger.warn('persist_working_failed', { agent: this.agentName, error: String(err) });
769
- }
770
- }
771
-
772
- // -- Long-term memory --
773
-
774
- /**
775
- * Store a long-term memory fact.
776
- */
777
- async remember(key: string, value: any, category: string = 'general'): Promise<void> {
778
- if (!this.db) {
779
- return;
780
- }
781
-
782
- this.dbRun(
783
- `INSERT INTO memories (agent, key, value, category) VALUES (?, ?, ?, ?)
784
- ON CONFLICT(agent, key) DO UPDATE SET value = excluded.value, category = excluded.category, updated_at = CURRENT_TIMESTAMP`,
785
- [this.agentName, key, JSON.stringify(value), category]
786
- );
787
- }
788
-
789
- /**
790
- * Recall long-term memories.
791
- */
792
- async recall(
793
- key?: string | null,
794
- category?: string | null,
795
- limit: number = 20
796
- ): Promise<Record<string, any>[]> {
797
- if (!this.db) {
798
- return [];
799
- }
800
-
801
- let query = 'SELECT key, value, category FROM memories WHERE agent = ?';
802
- const params: any[] = [this.agentName];
803
-
804
- if (key) {
805
- query += ' AND key LIKE ?';
806
- params.push(`%${key}%`);
807
- }
808
-
809
- if (category) {
810
- query += ' AND category = ?';
811
- params.push(category);
812
- }
813
-
814
- query += ' ORDER BY updated_at DESC LIMIT ?';
815
- params.push(limit);
816
-
817
- const rows = this.dbAll(query, params);
818
-
819
- return rows.map((r: any) => ({
820
- key: r.key,
821
- value: JSON.parse(r.value),
822
- category: r.category,
823
- }));
824
- }
825
-
826
- /**
827
- * Forget a memory.
828
- */
829
- async forget(key: string): Promise<void> {
830
- if (!this.db) {
831
- return;
832
- }
833
-
834
- this.dbRun(
835
- 'DELETE FROM memories WHERE agent = ? AND key = ?',
836
- [this.agentName, key]
837
- );
838
- }
839
-
840
- /**
841
- * Tokenize query for recall.
842
- */
843
- private static tokenizeForRecall(query: string): string[] {
844
- const out = new Set<string>();
845
- const text = query || '';
846
-
847
- // ASCII tokens first
848
- let match: RegExpExecArray | null;
849
- while ((match = ASCII_TOKEN_RE.exec(text)) !== null) {
850
- out.add(match[0]);
851
- }
852
-
853
- // CJK tokens (n-grams)
854
- for (const run of text.match(CJK_RUN_RE) || []) {
855
- for (const size of [3, 2]) {
856
- if (run.length < size) continue;
857
- for (let i = 0; i <= run.length - size; i++) {
858
- out.add(run.slice(i, i + size));
859
- }
860
- }
861
- }
862
-
863
- return Array.from(out);
864
- }
865
-
866
- /**
867
- * Recall for injection into prompts.
868
- */
869
- async recallForInjection(query: string, limit: number = 3): Promise<Record<string, any>[]> {
870
- if (!this.db || !query) {
871
- return [];
872
- }
873
-
874
- const tokens = Memory.tokenizeForRecall(query).slice(0, 24);
875
- const likeHits: Record<string, any>[] = [];
876
-
877
- // Pass 1: LIKE token scan
878
- if (tokens.length > 0) {
879
- const likeClauses = tokens.map(() => 'key LIKE ? OR value LIKE ?').join(' OR ');
880
- const params: any[] = [this.agentName];
881
-
882
- for (const tok of tokens) {
883
- const pattern = `%${tok}%`;
884
- params.push(pattern, pattern);
885
- }
886
-
887
- params.push(limit * 2);
888
-
889
- const sql = `SELECT key, value, category, updated_at FROM memories
890
- WHERE agent = ? AND (${likeClauses})
891
- ORDER BY updated_at DESC LIMIT ?`;
892
-
893
- const rows = this.dbAll(sql, params);
894
-
895
- for (const r of rows) {
896
- try {
897
- const val = JSON.parse(r.value);
898
- likeHits.push({
899
- key: r.key,
900
- value: val,
901
- category: r.category,
902
- updatedAt: r.updated_at,
903
- });
904
- } catch {
905
- likeHits.push({
906
- key: r.key,
907
- value: r.value,
908
- category: r.category,
909
- updatedAt: r.updated_at,
910
- });
911
- }
912
- }
913
- }
914
-
915
- // Pass 2: Semantic scoring (best-effort)
916
- const semanticHits: Record<string, any>[] = [];
917
- try {
918
- const seenKeys = new Set(likeHits.map(h => h.key));
919
- const rows = this.dbAll(
920
- 'SELECT key, value, category, updated_at FROM memories WHERE agent = ? ORDER BY updated_at DESC LIMIT 200',
921
- [this.agentName]
922
- );
923
-
924
- const candidates: Record<string, any>[] = [];
925
- for (const r of rows) {
926
- if (seenKeys.has(r.key)) continue;
927
- try {
928
- const val = JSON.parse(r.value);
929
- candidates.push({
930
- key: r.key,
931
- value: val,
932
- category: r.category,
933
- updatedAt: r.updated_at,
934
- });
935
- } catch {
936
- candidates.push({
937
- key: r.key,
938
- value: r.value,
939
- category: r.category,
940
- updatedAt: r.updated_at,
941
- });
942
- }
943
- }
944
-
945
- const scorer = getScorer();
946
- if (scorer) {
947
- const ranked = scorer.rank(query, candidates, 'value', limit, 0.03);
948
- for (const [_score, c] of ranked) {
949
- semanticHits.push(c);
950
- }
951
- }
952
- } catch (err) {
953
- // Semantic pass is best-effort
954
- logger.debug('recall_semantic_failed', { error: String(err) });
955
- }
956
-
957
- // Merge: LIKE first, then semantic
958
- const seen = new Set<string>();
959
- const merged: Record<string, any>[] = [];
960
-
961
- for (const src of [likeHits, semanticHits]) {
962
- for (const item of src) {
963
- const k = item.key;
964
- if (seen.has(k)) continue;
965
- seen.add(k);
966
- merged.push({ key: k, value: item.value, category: item.category });
967
- if (merged.length >= limit) {
968
- return merged;
969
- }
970
- }
971
- }
972
-
973
- return merged;
974
- }
975
-
976
- /**
977
- * Format facts as a markdown block for prompt injection.
978
- */
979
- static formatFactsBlock(facts: Record<string, any>[]): string {
980
- if (!facts || facts.length === 0) {
981
- return '';
982
- }
983
-
984
- const lines = ['## 相关记忆'];
985
- for (const f of facts) {
986
- let v = f.value;
987
- if (typeof v !== 'string') {
988
- try {
989
- v = JSON.stringify(v);
990
- } catch {
991
- v = String(v);
992
- }
993
- }
994
- lines.push(`- **${f.key}**: ${v}`);
995
- }
996
-
997
- return lines.join('\n');
998
- }
999
-
1000
- // -- Session management --
1001
-
1002
- /**
1003
- * Get active session ID.
1004
- */
1005
- getActiveSession(): string | null {
1006
- return this.activeSession;
1007
- }
1008
-
1009
- /**
1010
- * Create a new session.
1011
- */
1012
- async createSession(name?: string | null): Promise<string> {
1013
- const sessionId = Math.random().toString(36).slice(2, 14);
1014
- const preview = name || '';
1015
-
1016
- if (this.db) {
1017
- this.dbRun(
1018
- 'INSERT INTO sessions (id, agent, name, preview) VALUES (?, ?, ?, ?)',
1019
- [sessionId, this.agentName, name, preview]
1020
- );
1021
- }
1022
-
1023
- this.activeSession = sessionId;
1024
- this.shortTerm = this.shortTerm.filter(m => m.role === 'system');
1025
- this.loaded = true;
1026
-
1027
- return sessionId;
1028
- }
1029
-
1030
- /**
1031
- * List all sessions.
1032
- */
1033
- async listSessions(): Promise<Record<string, any>[]> {
1034
- if (!this.db) {
1035
- return [];
1036
- }
1037
-
1038
- const rows = this.dbAll(
1039
- 'SELECT id, agent, name, preview, message_count, created_at, updated_at FROM sessions WHERE agent = ? ORDER BY updated_at DESC LIMIT 50',
1040
- [this.agentName]
1041
- );
1042
-
1043
- return rows.map((r: any) => ({
1044
- id: r.id,
1045
- agent: r.agent,
1046
- name: r.name,
1047
- preview: r.preview,
1048
- messageCount: r.message_count,
1049
- createdAt: r.created_at,
1050
- updatedAt: r.updated_at,
1051
- }));
1052
- }
1053
-
1054
- /**
1055
- * Resume the latest session.
1056
- */
1057
- async resumeLatestSession(): Promise<string | null> {
1058
- if (!this.db || this.activeSession) {
1059
- return this.activeSession;
1060
- }
1061
-
1062
- const row = this.dbGet(
1063
- 'SELECT id FROM sessions WHERE agent = ? ORDER BY updated_at DESC LIMIT 1',
1064
- [this.agentName]
1065
- );
1066
-
1067
- if (!row) {
1068
- return null;
1069
- }
1070
-
1071
- this.activeSession = row.id;
1072
- this.loaded = false;
1073
- this.shortTerm = this.shortTerm.filter(m => m.role === 'system');
1074
- await this.loadShortTerm();
1075
-
1076
- return this.activeSession;
1077
- }
1078
-
1079
- /**
1080
- * Load a specific session.
1081
- */
1082
- async loadSession(sessionId: string): Promise<boolean> {
1083
- if (!this.db) {
1084
- return false;
1085
- }
1086
-
1087
- const row = this.dbGet(
1088
- 'SELECT id FROM sessions WHERE id = ? AND agent = ?',
1089
- [sessionId, this.agentName]
1090
- );
1091
-
1092
- if (!row) {
1093
- return false;
1094
- }
1095
-
1096
- this.activeSession = sessionId;
1097
- this.shortTerm = this.shortTerm.filter(m => m.role === 'system');
1098
- this.loaded = false;
1099
- await this.loadShortTerm();
1100
-
1101
- return true;
1102
- }
1103
-
1104
- /**
1105
- * Delete a session.
1106
- */
1107
- async deleteSession(sessionId: string): Promise<boolean> {
1108
- if (!this.db) {
1109
- return false;
1110
- }
1111
-
1112
- const row = this.dbGet(
1113
- 'SELECT id FROM sessions WHERE id = ? AND agent = ?',
1114
- [sessionId, this.agentName]
1115
- );
1116
-
1117
- if (!row) {
1118
- return false;
1119
- }
1120
-
1121
- this.dbRun(
1122
- 'DELETE FROM messages WHERE agent = ? AND session_id = ?',
1123
- [this.agentName, sessionId]
1124
- );
1125
-
1126
- this.dbRun(
1127
- 'DELETE FROM sessions WHERE id = ? AND agent = ?',
1128
- [sessionId, this.agentName]
1129
- );
1130
-
1131
- if (this.activeSession === sessionId) {
1132
- this.activeSession = null;
1133
- this.shortTerm = this.shortTerm.filter(m => m.role === 'system');
1134
- }
1135
-
1136
- return true;
1137
- }
1138
-
1139
- /**
1140
- * Update session preview.
1141
- */
1142
- async updateSessionPreview(): Promise<void> {
1143
- if (!this.db || !this.activeSession) {
1144
- return;
1145
- }
1146
-
1147
- const row = this.dbGet(
1148
- 'SELECT content FROM messages WHERE agent = ? AND session_id = ? AND role = ? ORDER BY id ASC LIMIT 1',
1149
- [this.agentName, this.activeSession, 'user']
1150
- );
1151
-
1152
- if (row) {
1153
- const preview = row.content.slice(0, 80);
1154
- this.dbRun(
1155
- 'UPDATE sessions SET preview = ? WHERE id = ?',
1156
- [preview, this.activeSession]
1157
- );
1158
- }
1159
- }
1160
-
1161
- /**
1162
- * Get memory statistics.
1163
- */
1164
- async getMemoryStats(): Promise<Record<string, any>> {
1165
- if (!this.db) {
1166
- return { total: 0, categories: {} };
1167
- }
1168
-
1169
- const rows = this.dbAll(
1170
- 'SELECT category, COUNT(*) as count FROM memories WHERE agent = ? GROUP BY category',
1171
- [this.agentName]
1172
- );
1173
-
1174
- const categories: Record<string, number> = {};
1175
- for (const row of rows) {
1176
- categories[row.category] = row.count;
1177
- }
1178
-
1179
- return {
1180
- total: Object.values(categories).reduce((a: number, b: number) => a + b, 0),
1181
- categories,
1182
- };
1183
- }
1184
- }
1185
-
1186
- /**
1187
- * Helper for expanding home directory paths.
1188
- */
1189
- function expandUserPath(filePath: string): string {
1190
- if (filePath.startsWith('~')) {
1191
- return path.join(os.homedir(), filePath.slice(1));
1192
- }
1193
- return filePath;
1194
- }
1195
-
1196
- /**
1197
- * ShortTermLock helper - exposes the lock for use by agent.ts
1198
- */
1199
- export function getShortTermLock(memory: Memory): SimpleMutex {
1200
- return (memory as any).shortTermLock;
1201
- }
1
+ /**
2
+ * Memory system: short-term context, long-term persistence, working memory.
3
+ *
4
+ * Three-layer memory for each agent with SQLite persistence.
5
+ * - Short-term: conversation context (persisted to SQLite)
6
+ * - Working: in-memory task-scoped state
7
+ * - Long-term: persistent key-value storage with search
8
+ */
9
+
10
+ import initSqlJs, { Database as SqlJsDatabase, SqlJsStatic } from 'sql.js';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import * as os from 'os';
14
+
15
+ import type { MemoryConfig } from './config';
16
+ import { getLogger } from './logger';
17
+ import { getScorer } from './semantic';
18
+
19
+ const logger = getLogger('memory');
20
+
21
+ // Token extraction patterns for fact-recall queries
22
+ const ASCII_TOKEN_RE = /[A-Za-z][A-Za-z0-9_+-]+/g;
23
+ const CJK_RUN_RE = /[一-鿿]+/g;
24
+
25
+ /**
26
+ * Message interface representing a conversation message.
27
+ */
28
+ export interface Message {
29
+ role: string;
30
+ content: string;
31
+ name?: string | null;
32
+ toolCallId?: string | null;
33
+ toolCalls?: Record<string, any>[] | null;
34
+ reasoningContent?: string | null;
35
+ }
36
+
37
+ /**
38
+ * Simple inline Mutex implementation (replaces async-lock dependency).
39
+ */
40
+ class SimpleMutex {
41
+ private _locked = false;
42
+ private _queue: Array<() => void> = [];
43
+
44
+ async lock<T>(fn: () => Promise<T>): Promise<T> {
45
+ await new Promise<void>((resolve) => {
46
+ if (!this._locked) {
47
+ this._locked = true;
48
+ resolve();
49
+ } else {
50
+ this._queue.push(resolve);
51
+ }
52
+ });
53
+ try {
54
+ return await fn();
55
+ } finally {
56
+ if (this._queue.length > 0) {
57
+ const next = this._queue.shift()!;
58
+ next();
59
+ } else {
60
+ this._locked = false;
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Three-layer memory system for agents.
68
+ */
69
+ export class Memory {
70
+ private config: MemoryConfig;
71
+ private agentName: string;
72
+ public shortTerm: Message[] = [];
73
+ public working: Record<string, any> = {};
74
+
75
+ private dbPath: string;
76
+ private db: SqlJsDatabase | null = null;
77
+ private SQL: SqlJsStatic | null = null;
78
+ private loaded = false;
79
+ private pendingPersists: Set<Promise<void>> = new Set();
80
+ private activeSession: string | null = null;
81
+ private saveTimer: ReturnType<typeof setTimeout> | null = null;
82
+
83
+ // short_term is mutated from both main chat loop and handlers
84
+ // All mutations go through a short critical section
85
+ private shortTermLock = new SimpleMutex();
86
+
87
+ constructor(config: MemoryConfig, agentName: string) {
88
+ this.config = config;
89
+ this.agentName = agentName;
90
+
91
+ const base = expandUserPath(config.dbPath);
92
+ this.dbPath = path.join(path.dirname(base), `${agentName}.db`);
93
+ }
94
+
95
+ /**
96
+ * Initialize the database and load persistent data.
97
+ */
98
+ async initDb(): Promise<void> {
99
+ if (this.db !== null) {
100
+ return;
101
+ }
102
+
103
+ // Initialize sql.js
104
+ this.SQL = await initSqlJs();
105
+
106
+ // Create directory if it doesn't exist
107
+ const dbDir = path.dirname(this.dbPath);
108
+ if (!fs.existsSync(dbDir)) {
109
+ fs.mkdirSync(dbDir, { recursive: true });
110
+ }
111
+
112
+ // Load existing database or create new
113
+ let dbBuffer: Buffer | null = null;
114
+ if (fs.existsSync(this.dbPath)) {
115
+ try {
116
+ dbBuffer = fs.readFileSync(this.dbPath);
117
+ } catch { /* read failed, start fresh */ }
118
+ }
119
+
120
+ this.db = new this.SQL.Database(dbBuffer || undefined);
121
+ this.db.run('PRAGMA journal_mode = MEMORY');
122
+ this.db.run('PRAGMA busy_timeout = 100');
123
+
124
+ // Create tables
125
+ this.db.run(`
126
+ CREATE TABLE IF NOT EXISTS memories (
127
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
128
+ agent TEXT NOT NULL,
129
+ key TEXT NOT NULL,
130
+ value TEXT NOT NULL,
131
+ category TEXT DEFAULT 'general',
132
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
133
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
134
+ )
135
+ `);
136
+
137
+ // Migrate existing DBs
138
+ try {
139
+ this.db.run("ALTER TABLE memories ADD COLUMN category TEXT DEFAULT 'general'");
140
+ } catch {
141
+ // Column already exists
142
+ }
143
+
144
+ try {
145
+ this.db.run('ALTER TABLE memories ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP');
146
+ } catch {
147
+ // Column already exists
148
+ }
149
+
150
+ // Ensure unique index
151
+ try {
152
+ this.db.run('DROP INDEX IF EXISTS idx_agent_key');
153
+ } catch {
154
+ // Ignore
155
+ }
156
+
157
+ this.db.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_key ON memories(agent, key)');
158
+ this.db.run('CREATE INDEX IF NOT EXISTS idx_agent_category ON memories(agent, category)');
159
+
160
+ this.db.run(`
161
+ CREATE TABLE IF NOT EXISTS messages (
162
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
163
+ agent TEXT NOT NULL,
164
+ role TEXT NOT NULL,
165
+ content TEXT NOT NULL,
166
+ name TEXT,
167
+ tool_call_id TEXT,
168
+ tool_calls TEXT,
169
+ reasoning_content TEXT,
170
+ session_id TEXT,
171
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
172
+ )
173
+ `);
174
+
175
+ this.db.run('CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(agent, created_at)');
176
+
177
+ try {
178
+ this.db.run('ALTER TABLE messages ADD COLUMN tool_calls TEXT');
179
+ } catch {
180
+ // Column already exists
181
+ }
182
+
183
+ try {
184
+ this.db.run('ALTER TABLE messages ADD COLUMN reasoning_content TEXT');
185
+ } catch {
186
+ // Column already exists
187
+ }
188
+
189
+ try {
190
+ this.db.run('ALTER TABLE messages ADD COLUMN session_id TEXT');
191
+ } catch {
192
+ // Column already exists
193
+ }
194
+
195
+ this.db.run(`
196
+ CREATE TABLE IF NOT EXISTS sessions (
197
+ id TEXT PRIMARY KEY,
198
+ agent TEXT NOT NULL,
199
+ name TEXT,
200
+ preview TEXT DEFAULT '',
201
+ message_count INTEGER DEFAULT 0,
202
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
203
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
204
+ )
205
+ `);
206
+
207
+ this.db.run('CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent, updated_at DESC)');
208
+
209
+ this.db.run(`
210
+ CREATE TABLE IF NOT EXISTS working_data (
211
+ agent TEXT NOT NULL,
212
+ key TEXT NOT NULL,
213
+ value TEXT NOT NULL,
214
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
215
+ PRIMARY KEY (agent, key)
216
+ )
217
+ `);
218
+
219
+ this.loadShortTerm();
220
+ this.loadWorking();
221
+ }
222
+
223
+ /**
224
+ * Persist the database to disk.
225
+ */
226
+ private persistDb(): void {
227
+ if (!this.db) return;
228
+ try {
229
+ const data = this.db.export();
230
+ fs.writeFileSync(this.dbPath, Buffer.from(data));
231
+ } catch (err) {
232
+ logger.warn('persist_db_failed', { path: this.dbPath, error: String(err) });
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Debounced save to disk. sql.js is in-memory, so without this the database
238
+ * file is never written and sessions / long-term memory would not survive a
239
+ * restart. Coalesces bursts of writes; the timer is unref'd so it never keeps
240
+ * the process alive (close() does the final synchronous save).
241
+ */
242
+ private scheduleSave(): void {
243
+ if (!this.db || this.saveTimer) return;
244
+ this.saveTimer = setTimeout(() => { this.saveTimer = null; this.persistDb(); }, 300);
245
+ if (typeof (this.saveTimer as any).unref === 'function') (this.saveTimer as any).unref();
246
+ }
247
+
248
+ /**
249
+ * Execute a SELECT query and return array of row objects.
250
+ */
251
+ private dbAll(sql: string, params?: any[]): any[] {
252
+ if (!this.db) return [];
253
+ try {
254
+ const stmt = this.db.prepare(sql);
255
+ if (params) stmt.bind(params);
256
+ const rows: any[] = [];
257
+ while (stmt.step()) {
258
+ rows.push(stmt.getAsObject());
259
+ }
260
+ stmt.free();
261
+ return rows;
262
+ } catch {
263
+ return [];
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Execute a SELECT query and return first row object.
269
+ */
270
+ private dbGet(sql: string, params?: any[]): any | null {
271
+ if (!this.db) return null;
272
+ try {
273
+ const stmt = this.db.prepare(sql);
274
+ if (params) stmt.bind(params);
275
+ let row: any = null;
276
+ if (stmt.step()) {
277
+ row = stmt.getAsObject();
278
+ }
279
+ stmt.free();
280
+ return row;
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Execute a statement and return this for chaining.
288
+ */
289
+ private dbRun(sql: string, params?: any[]): void {
290
+ if (!this.db) return;
291
+ try {
292
+ // Sql.js rejects `undefined` in bind arrays; normalize to `null`
293
+ const safe = params ? params.map((v) => v === undefined ? null : v) : undefined;
294
+ this.db.run(sql, safe);
295
+ this.scheduleSave(); // every write goes through here — persist (debounced)
296
+ } catch (err) {
297
+ logger.warn('db_run_failed', { sql: sql.slice(0, 80), error: String(err) });
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Load short-term memory from database.
303
+ */
304
+ private async loadShortTerm(): Promise<void> {
305
+ if (!this.db || this.loaded) {
306
+ return;
307
+ }
308
+
309
+ let rows: any[] = [];
310
+
311
+ if (this.activeSession) {
312
+ rows = this.dbAll(
313
+ `SELECT role, content, name, tool_call_id, tool_calls, reasoning_content, created_at
314
+ FROM messages
315
+ WHERE agent = ? AND session_id = ?
316
+ ORDER BY id DESC LIMIT ?`,
317
+ [this.agentName, this.activeSession, this.config.shortTermLimit]
318
+ );
319
+ } else {
320
+ this.loaded = true;
321
+ this.pruneDanglingToolCalls();
322
+ return;
323
+ }
324
+
325
+ // Truncate at timestamp gap
326
+ const gapSeconds = parseFloat(process.env.WA_RESUME_GAP_SECONDS || '14400'); // 4h default
327
+ rows = Memory.truncateAtTimestampGap(rows, gapSeconds);
328
+
329
+ for (const row of rows.reverse()) {
330
+ let toolCalls: Record<string, any>[] | null = null;
331
+ if (row.tool_calls) {
332
+ try {
333
+ toolCalls = JSON.parse(row.tool_calls);
334
+ } catch {
335
+ // Invalid JSON, skip
336
+ }
337
+ }
338
+
339
+ this.shortTerm.push({
340
+ role: row.role,
341
+ content: row.content,
342
+ name: row.name,
343
+ toolCallId: row.tool_call_id,
344
+ toolCalls,
345
+ reasoningContent: row.reasoning_content,
346
+ });
347
+ }
348
+
349
+ this.loaded = true;
350
+ this.pruneDanglingToolCalls();
351
+ }
352
+
353
+ /**
354
+ * Load working memory from database.
355
+ */
356
+ private async loadWorking(): Promise<void> {
357
+ if (!this.db) {
358
+ return;
359
+ }
360
+
361
+ const rows = this.dbAll(
362
+ 'SELECT key, value FROM working_data WHERE agent = ?',
363
+ [this.agentName]
364
+ );
365
+
366
+ for (const row of rows) {
367
+ try {
368
+ this.working[row.key] = JSON.parse(row.value);
369
+ } catch {
370
+ // Skip invalid JSON
371
+ }
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Keep only the contiguous tail of rows where consecutive timestamps
377
+ * are within gap_seconds of each other.
378
+ */
379
+ private static truncateAtTimestampGap(rows: any[], gapSeconds: number): any[] {
380
+ if (rows.length === 0 || gapSeconds <= 0) {
381
+ return rows;
382
+ }
383
+
384
+ const parseTs = (raw: any): Date | null => {
385
+ if (!raw) return null;
386
+ if (raw instanceof Date) return raw;
387
+ try {
388
+ return new Date(raw);
389
+ } catch {
390
+ return null;
391
+ }
392
+ };
393
+
394
+ const keep = [rows[0]];
395
+ let prevTs = parseTs(rows[0].created_at);
396
+
397
+ for (let i = 1; i < rows.length; i++) {
398
+ const curTs = parseTs(rows[i].created_at);
399
+ if (prevTs && curTs) {
400
+ const delta = Math.abs((prevTs.getTime() - curTs.getTime()) / 1000);
401
+ if (delta > gapSeconds) {
402
+ break;
403
+ }
404
+ }
405
+ keep.push(rows[i]);
406
+ if (curTs) {
407
+ prevTs = curTs;
408
+ }
409
+ }
410
+
411
+ return keep;
412
+ }
413
+
414
+ /**
415
+ * Remove orphaned tool_calls/tool message pairs from short-term memory.
416
+ */
417
+ private pruneDanglingToolCalls(): void {
418
+ if (this.shortTerm.length === 0) {
419
+ return;
420
+ }
421
+
422
+ const n = this.shortTerm.length;
423
+ const remove = new Array(n).fill(false);
424
+
425
+ // Pass 1: position-aware matching
426
+ const waiting: Record<string, number[]> = {};
427
+
428
+ for (let i = 0; i < this.shortTerm.length; i++) {
429
+ const msg = this.shortTerm[i];
430
+ if (msg.role === 'assistant' && msg.toolCalls) {
431
+ for (const tc of msg.toolCalls) {
432
+ const tid = tc.id;
433
+ if (tid) {
434
+ if (!waiting[tid]) waiting[tid] = [];
435
+ waiting[tid].push(i);
436
+ }
437
+ }
438
+ } else if (msg.role === 'tool' && msg.toolCallId) {
439
+ const tid = msg.toolCallId;
440
+ if (waiting[tid] && waiting[tid].length > 0) {
441
+ waiting[tid].pop();
442
+ } else {
443
+ remove[i] = true;
444
+ }
445
+ }
446
+ }
447
+
448
+ // Any assistant indices still in waiting stacks are orphaned
449
+ for (const indices of Object.values(waiting)) {
450
+ for (const i of indices) {
451
+ remove[i] = true;
452
+ }
453
+ }
454
+
455
+ if (!remove.some(r => r)) {
456
+ return;
457
+ }
458
+
459
+ const kept = this.shortTerm.filter((_, i) => !remove[i]);
460
+
461
+ // Pass 2: remove tool messages with no preceding assistant
462
+ const seenTcIds = new Set<string>();
463
+ const sanitized: Message[] = [];
464
+
465
+ for (const msg of kept) {
466
+ if (msg.role === 'assistant' && msg.toolCalls) {
467
+ for (const tc of msg.toolCalls) {
468
+ const tid = tc.id;
469
+ if (tid) {
470
+ seenTcIds.add(tid);
471
+ }
472
+ }
473
+ } else if (msg.role === 'tool' && msg.toolCallId && !seenTcIds.has(msg.toolCallId)) {
474
+ continue;
475
+ }
476
+ sanitized.push(msg);
477
+ }
478
+
479
+ this.shortTerm = sanitized;
480
+ }
481
+
482
+ /**
483
+ * Public wrapper around pruneDanglingToolCalls.
484
+ */
485
+ public pruneToolMessages(): void {
486
+ this.pruneDanglingToolCalls();
487
+ }
488
+
489
+ /**
490
+ * Flush pending operations and close database.
491
+ */
492
+ async close(): Promise<void> {
493
+ if (this.db) {
494
+ if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; }
495
+ await this.flushPending();
496
+ this.persistDb(); // final synchronous save to disk
497
+ this.db.close();
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Wait for all pending persist operations to complete.
503
+ */
504
+ private async flushPending(): Promise<void> {
505
+ if (this.pendingPersists.size > 0) {
506
+ const results = await Promise.allSettled(Array.from(this.pendingPersists));
507
+ for (const result of results) {
508
+ if (result.status === 'rejected') {
509
+ logger.warn('flush_persist_failed', { agent: this.agentName, error: String(result.reason) });
510
+ }
511
+ }
512
+ this.pendingPersists.clear();
513
+ }
514
+ }
515
+
516
+ // -- Short-term memory --
517
+
518
+ /**
519
+ * Add a message to short-term memory.
520
+ */
521
+ public addMessage(role: string, content: string, kwargs?: Record<string, any>): void {
522
+ const msg: Message = {
523
+ role,
524
+ content,
525
+ name: kwargs?.name,
526
+ toolCallId: kwargs?.toolCallId,
527
+ toolCalls: kwargs?.toolCalls,
528
+ reasoningContent: kwargs?.reasoningContent,
529
+ };
530
+
531
+ const ephemeral = kwargs?.ephemeral ?? false;
532
+
533
+ // Push synchronously so getMessages()/shortTerm reflect the message in the
534
+ // same tick — callers (chatImpl/chatStreamImpl) read it immediately after.
535
+ // (Single-threaded JS makes the push atomic; only the array-rewriting prune
536
+ // needs the mutex.) Previously the push lived inside the async lock body,
537
+ // so a fresh session's first user message was missing from the first LLM
538
+ // request and messagesWithRecall() crashed on undefined.content.
539
+ this.shortTerm.push(msg);
540
+
541
+ if (this.shortTerm.length > this.config.shortTermLimit) {
542
+ this.shortTermLock.lock(async () => {
543
+ if (this.shortTerm.length > this.config.shortTermLimit) {
544
+ const systemMsgs = this.shortTerm.filter(m => m.role === 'system');
545
+ const otherMsgs = this.shortTerm.filter(m => m.role !== 'system');
546
+ const keep = Math.max(0, this.config.shortTermLimit - systemMsgs.length);
547
+ this.shortTerm = systemMsgs.concat(keep > 0 ? otherMsgs.slice(-keep) : []);
548
+ this.pruneDanglingToolCalls();
549
+ }
550
+ });
551
+ }
552
+
553
+ // Persist message if not ephemeral
554
+ if (this.db && role !== 'system' && !ephemeral) {
555
+ const toolCallsJson = msg.toolCalls ? JSON.stringify(msg.toolCalls) : null;
556
+ const sessionId = this.activeSession;
557
+
558
+ const promise = this.persistMessage(
559
+ role,
560
+ content,
561
+ msg.name || null,
562
+ msg.toolCallId || null,
563
+ toolCallsJson,
564
+ msg.reasoningContent || null,
565
+ sessionId
566
+ );
567
+
568
+ this.pendingPersists.add(promise);
569
+ promise.then(() => {
570
+ this.pendingPersists.delete(promise);
571
+ }).catch(() => {
572
+ this.pendingPersists.delete(promise);
573
+ });
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Persist a message to database.
579
+ */
580
+ private async persistMessage(
581
+ role: string,
582
+ content: string,
583
+ name: string | null,
584
+ toolCallId: string | null,
585
+ toolCalls: string | null = null,
586
+ reasoningContent: string | null = null,
587
+ sessionId: string | null = null
588
+ ): Promise<void> {
589
+ if (!this.db) {
590
+ return;
591
+ }
592
+
593
+ try {
594
+ this.dbRun(
595
+ `INSERT INTO messages (agent, role, content, name, tool_call_id, tool_calls, reasoning_content, session_id)
596
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
597
+ [this.agentName, role, content, name, toolCallId, toolCalls, reasoningContent, sessionId]
598
+ );
599
+
600
+ if (sessionId) {
601
+ this.dbRun(
602
+ 'UPDATE sessions SET message_count = message_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
603
+ [sessionId]
604
+ );
605
+ }
606
+
607
+ // Auto-prune old messages
608
+ const maxPersisted = this.config.maxPersistedMessages || 1000;
609
+ if (sessionId && maxPersisted > 0) {
610
+ const row = this.dbGet(
611
+ 'SELECT COUNT(*) as count FROM messages WHERE agent = ? AND session_id = ? AND role != ?',
612
+ [this.agentName, sessionId, 'system']
613
+ );
614
+ if (row && row.count > maxPersisted) {
615
+ const excess = row.count - maxPersisted;
616
+ this.dbRun(
617
+ `DELETE FROM messages WHERE id IN (
618
+ SELECT id FROM messages WHERE agent = ? AND session_id = ? AND role != ?
619
+ ORDER BY id ASC LIMIT ?
620
+ )`,
621
+ [this.agentName, sessionId, 'system', excess]
622
+ );
623
+ }
624
+ }
625
+ } catch (err) {
626
+ logger.warn('persist_message_failed', { agent: this.agentName, error: String(err) });
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Get messages as a list of dicts compatible with LLM API.
632
+ */
633
+ public getMessages(): Record<string, any>[] {
634
+ this.pruneDanglingToolCalls();
635
+ const msgs: Record<string, any>[] = [];
636
+
637
+ for (const m of this.shortTerm) {
638
+ const d: Record<string, any> = { role: m.role, content: m.content };
639
+ if (m.name) d.name = m.name;
640
+ if (m.toolCallId) d.tool_call_id = m.toolCallId;
641
+ if (m.toolCalls) d.tool_calls = m.toolCalls;
642
+ if (m.reasoningContent) d.reasoning_content = m.reasoningContent;
643
+ msgs.push(d);
644
+ }
645
+
646
+ return msgs;
647
+ }
648
+
649
+ /**
650
+ * Return stats about current memory usage.
651
+ */
652
+ public getContextWindowUsage(): Record<string, number> {
653
+ let totalChars = 0;
654
+ let cjk = 0;
655
+
656
+ for (const m of this.shortTerm) {
657
+ // Count the tool-call payload too: a single tool call with large JSON args
658
+ // occupies real context, and ignoring it made tool-heavy turns under-count
659
+ // and auto-compaction fire too late.
660
+ let text = m.content || '';
661
+ if (m.toolCalls) {
662
+ try { text += JSON.stringify(m.toolCalls); } catch { /* unserializable — skip */ }
663
+ }
664
+ totalChars += text.length;
665
+ for (const c of text) {
666
+ const code = c.charCodeAt(0);
667
+ if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3000 && code <= 0x303f)) {
668
+ cjk++;
669
+ }
670
+ }
671
+ }
672
+
673
+ const other = totalChars - cjk;
674
+ return {
675
+ messageCount: this.shortTerm.length,
676
+ totalChars,
677
+ estimatedTokens: Math.max(1, cjk * 2 + Math.floor(other / 4)),
678
+ limit: this.config.shortTermLimit,
679
+ };
680
+ }
681
+
682
+ /**
683
+ * Clear in-memory short-term memory.
684
+ */
685
+ async clearShortTerm(): Promise<void> {
686
+ const systemMsgs = this.shortTerm.filter(m => m.role === 'system');
687
+ this.shortTerm = systemMsgs;
688
+
689
+ if (!this.db) {
690
+ return;
691
+ }
692
+
693
+ if (this.activeSession !== null) {
694
+ this.dbRun(
695
+ 'DELETE FROM messages WHERE agent = ? AND role != ? AND session_id = ?',
696
+ [this.agentName, 'system', this.activeSession]
697
+ );
698
+
699
+ this.dbRun(
700
+ 'UPDATE sessions SET message_count = 0 WHERE id = ?',
701
+ [this.activeSession]
702
+ );
703
+ } else {
704
+ this.dbRun(
705
+ 'DELETE FROM messages WHERE agent = ? AND role != ? AND session_id IS NULL',
706
+ [this.agentName, 'system']
707
+ );
708
+ }
709
+ }
710
+
711
+ // -- Working memory --
712
+
713
+ /**
714
+ * Set a working memory value.
715
+ */
716
+ public setWorking(key: string, value: any): void {
717
+ this.working[key] = value;
718
+ this.schedulePersistWorking();
719
+ }
720
+
721
+ /**
722
+ * Get a working memory value.
723
+ */
724
+ public getWorking(key: string, defaultValue: any = null): any {
725
+ return this.working[key] ?? defaultValue;
726
+ }
727
+
728
+ /**
729
+ * Clear all working memory.
730
+ */
731
+ public clearWorking(): void {
732
+ this.working = {};
733
+ this.schedulePersistWorking();
734
+ }
735
+
736
+ private schedulePersistWorking(): void {
737
+ if (!this.db) {
738
+ return;
739
+ }
740
+
741
+ const promise = this.persistWorking();
742
+ this.pendingPersists.add(promise);
743
+ promise.then(() => {
744
+ this.pendingPersists.delete(promise);
745
+ }).catch(() => {
746
+ this.pendingPersists.delete(promise);
747
+ });
748
+ }
749
+
750
+ private async persistWorking(): Promise<void> {
751
+ if (!this.db) {
752
+ return;
753
+ }
754
+
755
+ try {
756
+ this.dbRun(
757
+ 'DELETE FROM working_data WHERE agent = ?',
758
+ [this.agentName]
759
+ );
760
+
761
+ for (const [key, value] of Object.entries(this.working)) {
762
+ this.dbRun(
763
+ 'INSERT INTO working_data (agent, key, value) VALUES (?, ?, ?)',
764
+ [this.agentName, key, JSON.stringify(value)]
765
+ );
766
+ }
767
+ } catch (err) {
768
+ logger.warn('persist_working_failed', { agent: this.agentName, error: String(err) });
769
+ }
770
+ }
771
+
772
+ // -- Long-term memory --
773
+
774
+ /**
775
+ * Store a long-term memory fact.
776
+ */
777
+ async remember(key: string, value: any, category: string = 'general'): Promise<void> {
778
+ if (!this.db) {
779
+ return;
780
+ }
781
+
782
+ this.dbRun(
783
+ `INSERT INTO memories (agent, key, value, category) VALUES (?, ?, ?, ?)
784
+ ON CONFLICT(agent, key) DO UPDATE SET value = excluded.value, category = excluded.category, updated_at = CURRENT_TIMESTAMP`,
785
+ [this.agentName, key, JSON.stringify(value), category]
786
+ );
787
+ }
788
+
789
+ /**
790
+ * Recall long-term memories.
791
+ */
792
+ async recall(
793
+ key?: string | null,
794
+ category?: string | null,
795
+ limit: number = 20
796
+ ): Promise<Record<string, any>[]> {
797
+ if (!this.db) {
798
+ return [];
799
+ }
800
+
801
+ let query = 'SELECT key, value, category FROM memories WHERE agent = ?';
802
+ const params: any[] = [this.agentName];
803
+
804
+ if (key) {
805
+ query += ' AND key LIKE ?';
806
+ params.push(`%${key}%`);
807
+ }
808
+
809
+ if (category) {
810
+ query += ' AND category = ?';
811
+ params.push(category);
812
+ }
813
+
814
+ query += ' ORDER BY updated_at DESC LIMIT ?';
815
+ params.push(limit);
816
+
817
+ const rows = this.dbAll(query, params);
818
+
819
+ return rows.map((r: any) => ({
820
+ key: r.key,
821
+ value: JSON.parse(r.value),
822
+ category: r.category,
823
+ }));
824
+ }
825
+
826
+ /**
827
+ * Forget a memory.
828
+ */
829
+ async forget(key: string): Promise<void> {
830
+ if (!this.db) {
831
+ return;
832
+ }
833
+
834
+ this.dbRun(
835
+ 'DELETE FROM memories WHERE agent = ? AND key = ?',
836
+ [this.agentName, key]
837
+ );
838
+ }
839
+
840
+ /**
841
+ * Tokenize query for recall.
842
+ */
843
+ private static tokenizeForRecall(query: string): string[] {
844
+ const out = new Set<string>();
845
+ const text = query || '';
846
+
847
+ // ASCII tokens first
848
+ let match: RegExpExecArray | null;
849
+ while ((match = ASCII_TOKEN_RE.exec(text)) !== null) {
850
+ out.add(match[0]);
851
+ }
852
+
853
+ // CJK tokens (n-grams)
854
+ for (const run of text.match(CJK_RUN_RE) || []) {
855
+ for (const size of [3, 2]) {
856
+ if (run.length < size) continue;
857
+ for (let i = 0; i <= run.length - size; i++) {
858
+ out.add(run.slice(i, i + size));
859
+ }
860
+ }
861
+ }
862
+
863
+ return Array.from(out);
864
+ }
865
+
866
+ /**
867
+ * Recall for injection into prompts.
868
+ */
869
+ async recallForInjection(query: string, limit: number = 3): Promise<Record<string, any>[]> {
870
+ if (!this.db || !query) {
871
+ return [];
872
+ }
873
+
874
+ const tokens = Memory.tokenizeForRecall(query).slice(0, 24);
875
+ const likeHits: Record<string, any>[] = [];
876
+
877
+ // Pass 1: LIKE token scan
878
+ if (tokens.length > 0) {
879
+ const likeClauses = tokens.map(() => 'key LIKE ? OR value LIKE ?').join(' OR ');
880
+ const params: any[] = [this.agentName];
881
+
882
+ for (const tok of tokens) {
883
+ const pattern = `%${tok}%`;
884
+ params.push(pattern, pattern);
885
+ }
886
+
887
+ params.push(limit * 2);
888
+
889
+ const sql = `SELECT key, value, category, updated_at FROM memories
890
+ WHERE agent = ? AND (${likeClauses})
891
+ ORDER BY updated_at DESC LIMIT ?`;
892
+
893
+ const rows = this.dbAll(sql, params);
894
+
895
+ for (const r of rows) {
896
+ try {
897
+ const val = JSON.parse(r.value);
898
+ likeHits.push({
899
+ key: r.key,
900
+ value: val,
901
+ category: r.category,
902
+ updatedAt: r.updated_at,
903
+ });
904
+ } catch {
905
+ likeHits.push({
906
+ key: r.key,
907
+ value: r.value,
908
+ category: r.category,
909
+ updatedAt: r.updated_at,
910
+ });
911
+ }
912
+ }
913
+ }
914
+
915
+ // Pass 2: Semantic scoring (best-effort)
916
+ const semanticHits: Record<string, any>[] = [];
917
+ try {
918
+ const seenKeys = new Set(likeHits.map(h => h.key));
919
+ const rows = this.dbAll(
920
+ 'SELECT key, value, category, updated_at FROM memories WHERE agent = ? ORDER BY updated_at DESC LIMIT 200',
921
+ [this.agentName]
922
+ );
923
+
924
+ const candidates: Record<string, any>[] = [];
925
+ for (const r of rows) {
926
+ if (seenKeys.has(r.key)) continue;
927
+ try {
928
+ const val = JSON.parse(r.value);
929
+ candidates.push({
930
+ key: r.key,
931
+ value: val,
932
+ category: r.category,
933
+ updatedAt: r.updated_at,
934
+ });
935
+ } catch {
936
+ candidates.push({
937
+ key: r.key,
938
+ value: r.value,
939
+ category: r.category,
940
+ updatedAt: r.updated_at,
941
+ });
942
+ }
943
+ }
944
+
945
+ const scorer = getScorer();
946
+ if (scorer) {
947
+ const ranked = scorer.rank(query, candidates, 'value', limit, 0.03);
948
+ for (const [_score, c] of ranked) {
949
+ semanticHits.push(c);
950
+ }
951
+ }
952
+ } catch (err) {
953
+ // Semantic pass is best-effort
954
+ logger.debug('recall_semantic_failed', { error: String(err) });
955
+ }
956
+
957
+ // Merge: LIKE first, then semantic
958
+ const seen = new Set<string>();
959
+ const merged: Record<string, any>[] = [];
960
+
961
+ for (const src of [likeHits, semanticHits]) {
962
+ for (const item of src) {
963
+ const k = item.key;
964
+ if (seen.has(k)) continue;
965
+ seen.add(k);
966
+ merged.push({ key: k, value: item.value, category: item.category });
967
+ if (merged.length >= limit) {
968
+ return merged;
969
+ }
970
+ }
971
+ }
972
+
973
+ return merged;
974
+ }
975
+
976
+ /**
977
+ * Format facts as a markdown block for prompt injection.
978
+ */
979
+ static formatFactsBlock(facts: Record<string, any>[]): string {
980
+ if (!facts || facts.length === 0) {
981
+ return '';
982
+ }
983
+
984
+ const lines = ['## 相关记忆'];
985
+ for (const f of facts) {
986
+ let v = f.value;
987
+ if (typeof v !== 'string') {
988
+ try {
989
+ v = JSON.stringify(v);
990
+ } catch {
991
+ v = String(v);
992
+ }
993
+ }
994
+ lines.push(`- **${f.key}**: ${v}`);
995
+ }
996
+
997
+ return lines.join('\n');
998
+ }
999
+
1000
+ // -- Session management --
1001
+
1002
+ /**
1003
+ * Get active session ID.
1004
+ */
1005
+ getActiveSession(): string | null {
1006
+ return this.activeSession;
1007
+ }
1008
+
1009
+ /**
1010
+ * Create a new session.
1011
+ */
1012
+ async createSession(name?: string | null): Promise<string> {
1013
+ const sessionId = Math.random().toString(36).slice(2, 14);
1014
+ const preview = name || '';
1015
+
1016
+ if (this.db) {
1017
+ this.dbRun(
1018
+ 'INSERT INTO sessions (id, agent, name, preview) VALUES (?, ?, ?, ?)',
1019
+ [sessionId, this.agentName, name, preview]
1020
+ );
1021
+ }
1022
+
1023
+ this.activeSession = sessionId;
1024
+ this.shortTerm = this.shortTerm.filter(m => m.role === 'system');
1025
+ this.loaded = true;
1026
+
1027
+ return sessionId;
1028
+ }
1029
+
1030
+ /**
1031
+ * List all sessions.
1032
+ */
1033
+ async listSessions(): Promise<Record<string, any>[]> {
1034
+ if (!this.db) {
1035
+ return [];
1036
+ }
1037
+
1038
+ const rows = this.dbAll(
1039
+ 'SELECT id, agent, name, preview, message_count, created_at, updated_at FROM sessions WHERE agent = ? ORDER BY updated_at DESC LIMIT 50',
1040
+ [this.agentName]
1041
+ );
1042
+
1043
+ return rows.map((r: any) => ({
1044
+ id: r.id,
1045
+ agent: r.agent,
1046
+ name: r.name,
1047
+ preview: r.preview,
1048
+ messageCount: r.message_count,
1049
+ createdAt: r.created_at,
1050
+ updatedAt: r.updated_at,
1051
+ }));
1052
+ }
1053
+
1054
+ /**
1055
+ * Resume the latest session.
1056
+ */
1057
+ async resumeLatestSession(): Promise<string | null> {
1058
+ if (!this.db || this.activeSession) {
1059
+ return this.activeSession;
1060
+ }
1061
+
1062
+ const row = this.dbGet(
1063
+ 'SELECT id FROM sessions WHERE agent = ? ORDER BY updated_at DESC LIMIT 1',
1064
+ [this.agentName]
1065
+ );
1066
+
1067
+ if (!row) {
1068
+ return null;
1069
+ }
1070
+
1071
+ this.activeSession = row.id;
1072
+ this.loaded = false;
1073
+ this.shortTerm = this.shortTerm.filter(m => m.role === 'system');
1074
+ await this.loadShortTerm();
1075
+
1076
+ return this.activeSession;
1077
+ }
1078
+
1079
+ /**
1080
+ * Load a specific session.
1081
+ */
1082
+ async loadSession(sessionId: string): Promise<boolean> {
1083
+ if (!this.db) {
1084
+ return false;
1085
+ }
1086
+
1087
+ const row = this.dbGet(
1088
+ 'SELECT id FROM sessions WHERE id = ? AND agent = ?',
1089
+ [sessionId, this.agentName]
1090
+ );
1091
+
1092
+ if (!row) {
1093
+ return false;
1094
+ }
1095
+
1096
+ this.activeSession = sessionId;
1097
+ this.shortTerm = this.shortTerm.filter(m => m.role === 'system');
1098
+ this.loaded = false;
1099
+ await this.loadShortTerm();
1100
+
1101
+ return true;
1102
+ }
1103
+
1104
+ /**
1105
+ * Delete a session.
1106
+ */
1107
+ async deleteSession(sessionId: string): Promise<boolean> {
1108
+ if (!this.db) {
1109
+ return false;
1110
+ }
1111
+
1112
+ const row = this.dbGet(
1113
+ 'SELECT id FROM sessions WHERE id = ? AND agent = ?',
1114
+ [sessionId, this.agentName]
1115
+ );
1116
+
1117
+ if (!row) {
1118
+ return false;
1119
+ }
1120
+
1121
+ this.dbRun(
1122
+ 'DELETE FROM messages WHERE agent = ? AND session_id = ?',
1123
+ [this.agentName, sessionId]
1124
+ );
1125
+
1126
+ this.dbRun(
1127
+ 'DELETE FROM sessions WHERE id = ? AND agent = ?',
1128
+ [sessionId, this.agentName]
1129
+ );
1130
+
1131
+ if (this.activeSession === sessionId) {
1132
+ this.activeSession = null;
1133
+ this.shortTerm = this.shortTerm.filter(m => m.role === 'system');
1134
+ }
1135
+
1136
+ return true;
1137
+ }
1138
+
1139
+ /**
1140
+ * Update session preview.
1141
+ */
1142
+ async updateSessionPreview(): Promise<void> {
1143
+ if (!this.db || !this.activeSession) {
1144
+ return;
1145
+ }
1146
+
1147
+ const row = this.dbGet(
1148
+ 'SELECT content FROM messages WHERE agent = ? AND session_id = ? AND role = ? ORDER BY id ASC LIMIT 1',
1149
+ [this.agentName, this.activeSession, 'user']
1150
+ );
1151
+
1152
+ if (row) {
1153
+ const preview = row.content.slice(0, 80);
1154
+ this.dbRun(
1155
+ 'UPDATE sessions SET preview = ? WHERE id = ?',
1156
+ [preview, this.activeSession]
1157
+ );
1158
+ }
1159
+ }
1160
+
1161
+ /**
1162
+ * Get memory statistics.
1163
+ */
1164
+ async getMemoryStats(): Promise<Record<string, any>> {
1165
+ if (!this.db) {
1166
+ return { total: 0, categories: {} };
1167
+ }
1168
+
1169
+ const rows = this.dbAll(
1170
+ 'SELECT category, COUNT(*) as count FROM memories WHERE agent = ? GROUP BY category',
1171
+ [this.agentName]
1172
+ );
1173
+
1174
+ const categories: Record<string, number> = {};
1175
+ for (const row of rows) {
1176
+ categories[row.category] = row.count;
1177
+ }
1178
+
1179
+ return {
1180
+ total: Object.values(categories).reduce((a: number, b: number) => a + b, 0),
1181
+ categories,
1182
+ };
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * Helper for expanding home directory paths.
1188
+ */
1189
+ function expandUserPath(filePath: string): string {
1190
+ if (filePath.startsWith('~')) {
1191
+ return path.join(os.homedir(), filePath.slice(1));
1192
+ }
1193
+ return filePath;
1194
+ }
1195
+
1196
+ /**
1197
+ * ShortTermLock helper - exposes the lock for use by agent.ts
1198
+ */
1199
+ export function getShortTermLock(memory: Memory): SimpleMutex {
1200
+ return (memory as any).shortTermLock;
1201
+ }