agent-working-memory 0.7.4 → 0.7.6
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/README.md +6 -1
- package/dist/api/routes.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/core/salience.d.ts +2 -0
- package/dist/core/salience.d.ts.map +1 -1
- package/dist/core/salience.js +51 -0
- package/dist/core/salience.js.map +1 -1
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +3 -1
- package/dist/engine/activation.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp.js +2 -2
- package/dist/storage/sqlite.d.ts +9 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +75 -10
- package/dist/storage/sqlite.js.map +1 -1
- package/package.json +57 -57
- package/src/api/routes.ts +723 -723
- package/src/cli.ts +719 -719
- package/src/core/salience.ts +48 -0
- package/src/engine/activation.ts +3 -1
- package/src/index.ts +199 -199
- package/src/mcp.ts +1192 -1192
- package/src/storage/sqlite.ts +77 -10
package/src/core/salience.ts
CHANGED
|
@@ -41,6 +41,41 @@ export function detectUserFeedback(content: string): boolean {
|
|
|
41
41
|
return USER_FEEDBACK_PATTERN.test(content.trim());
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Auto-detect verified operational findings: batch records, completion summaries,
|
|
46
|
+
* incident reconciliations. These have low BM25 novelty (terminology repeats across
|
|
47
|
+
* runs — "USEF results submission", "Freshdesk triage batch") but the SPECIFIC
|
|
48
|
+
* event/ticket IDs, dates, and counts make each one uniquely valuable for future
|
|
49
|
+
* recall.
|
|
50
|
+
*
|
|
51
|
+
* Why this exists: the salience filter discarded a 6-event USEF batch summary at
|
|
52
|
+
* 0.14 (verified in activity log 2026-05-07T18:44:14) because the topic words
|
|
53
|
+
* collided with the long-running USEF history. The procedural memory beside it
|
|
54
|
+
* scored 0.70 — same topic, different content shape. The novelty signal alone
|
|
55
|
+
* can't distinguish a useful operational record from a duplicate observation.
|
|
56
|
+
*
|
|
57
|
+
* Pattern requires BOTH:
|
|
58
|
+
* 1. An action-verb header (Submitted/Finalized/Completed/Reconciled/Triaged/Posted/Resolved/Stamped)
|
|
59
|
+
* 2. At least 2 concrete identifiers — absolute dates (YYYY-MM-DD) OR numeric IDs
|
|
60
|
+
* with context (event \d+, ticket #\d+, USEF \d+, USEA \d+).
|
|
61
|
+
*
|
|
62
|
+
* Matched memories get a salience floor of 0.45 (active, but below canonical
|
|
63
|
+
* 0.7) — preserves the record without claiming source-of-truth status.
|
|
64
|
+
*/
|
|
65
|
+
const OPERATIONAL_VERB_PATTERN = /\b(Submitted|Finalized|Completed|Reconciled|Triaged|Posted|Resolved|Stamped|Pushed|Deployed|Migrated|Imported|Exported|Backfilled)\b/i;
|
|
66
|
+
const ISO_DATE_PATTERN = /\b\d{4}-\d{2}-\d{2}\b/g;
|
|
67
|
+
const CONCRETE_ID_PATTERN = /\b(?:events?|tickets?|comps?|comp_id|usef|usea|classes|class|cases?|orders?|payments?|member_id|horse_id|user_id|orgs?|#)\s*[#:]?\s*\d{3,}/gi;
|
|
68
|
+
|
|
69
|
+
/** Returns true if the content looks like a verified operational/batch record that should auto-bump salience. */
|
|
70
|
+
export function detectVerifiedFinding(content: string): boolean {
|
|
71
|
+
if (typeof content !== 'string' || content.length === 0) return false;
|
|
72
|
+
const text = content.trim();
|
|
73
|
+
if (!OPERATIONAL_VERB_PATTERN.test(text)) return false;
|
|
74
|
+
const dateCount = (text.match(ISO_DATE_PATTERN) || []).length;
|
|
75
|
+
const idCount = (text.match(CONCRETE_ID_PATTERN) || []).length;
|
|
76
|
+
return dateCount + idCount >= 2;
|
|
77
|
+
}
|
|
78
|
+
|
|
44
79
|
export interface SalienceInput {
|
|
45
80
|
content: string;
|
|
46
81
|
eventType?: SalienceEventType;
|
|
@@ -88,10 +123,19 @@ export function evaluateSalience(
|
|
|
88
123
|
let resolvedEventType: SalienceEventType = input.eventType ?? 'observation';
|
|
89
124
|
let resolvedMemoryClass: MemoryClass = input.memoryClass ?? 'working';
|
|
90
125
|
let autoPromoted = false;
|
|
126
|
+
let verifiedFindingFloor = false;
|
|
91
127
|
if (detectUserFeedback(input.content)) {
|
|
92
128
|
resolvedEventType = 'user_feedback';
|
|
93
129
|
resolvedMemoryClass = 'canonical';
|
|
94
130
|
autoPromoted = true;
|
|
131
|
+
} else if (detectVerifiedFinding(input.content)) {
|
|
132
|
+
// Operational record: bump eventType to 'decision' (typeBonus +0.15) and
|
|
133
|
+
// remember to apply a 0.45 salience floor below. Do NOT promote to canonical
|
|
134
|
+
// — these records are verified, not source-of-truth.
|
|
135
|
+
if (resolvedEventType === 'observation') {
|
|
136
|
+
resolvedEventType = 'decision';
|
|
137
|
+
}
|
|
138
|
+
verifiedFindingFloor = true;
|
|
95
139
|
}
|
|
96
140
|
|
|
97
141
|
const features: SalienceFeatures = {
|
|
@@ -104,6 +148,7 @@ export function evaluateSalience(
|
|
|
104
148
|
|
|
105
149
|
const reasonCodes: string[] = [];
|
|
106
150
|
if (autoPromoted) reasonCodes.push('auto:user_feedback');
|
|
151
|
+
if (verifiedFindingFloor) reasonCodes.push('auto:verified_finding');
|
|
107
152
|
|
|
108
153
|
// Novelty: 1.0 = completely new info, 0 = exact duplicate exists
|
|
109
154
|
// Default to 0.8 (assume mostly novel) when caller doesn't check
|
|
@@ -145,6 +190,9 @@ export function evaluateSalience(
|
|
|
145
190
|
reasonCodes.push('class:canonical');
|
|
146
191
|
} else if (memoryClass === 'ephemeral') {
|
|
147
192
|
reasonCodes.push('class:ephemeral');
|
|
193
|
+
} else if (verifiedFindingFloor) {
|
|
194
|
+
// Verified operational record: 0.45 floor — keeps it active without canonical promotion
|
|
195
|
+
score = Math.max(score, 0.45);
|
|
148
196
|
}
|
|
149
197
|
|
|
150
198
|
let disposition: 'active' | 'staging' | 'discard';
|
package/src/engine/activation.ts
CHANGED
|
@@ -294,9 +294,11 @@ export class ActivationEngine {
|
|
|
294
294
|
const simStdDev = Math.max(rawStdDev, 0.10);
|
|
295
295
|
|
|
296
296
|
// Phase 3b: Score each candidate with per-phase breakdown
|
|
297
|
+
// Batch-fetch associations for all candidates at once (was N+1, now 1 query)
|
|
298
|
+
const associationsByEngram = this.store.getAssociationsForBatch(candidates.map(e => e.id));
|
|
297
299
|
const scored = candidates.map(engram => {
|
|
298
300
|
const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
299
|
-
const associations =
|
|
301
|
+
const associations = associationsByEngram.get(engram.id) ?? [];
|
|
300
302
|
|
|
301
303
|
// --- Text relevance (keyword signals) ---
|
|
302
304
|
|
package/src/index.ts
CHANGED
|
@@ -1,199 +1,199 @@
|
|
|
1
|
-
// Copyright 2026 Robert Winter / Complete Ideas
|
|
2
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
-
import { readFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
|
|
4
|
-
import { resolve, dirname, basename } from 'node:path';
|
|
5
|
-
import Fastify from 'fastify';
|
|
6
|
-
|
|
7
|
-
// Load .env file if present (no external dependency)
|
|
8
|
-
try {
|
|
9
|
-
const envPath = resolve(process.cwd(), '.env');
|
|
10
|
-
const envContent = readFileSync(envPath, 'utf-8');
|
|
11
|
-
for (const line of envContent.split('\n')) {
|
|
12
|
-
const trimmed = line.trim();
|
|
13
|
-
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
14
|
-
const eqIdx = trimmed.indexOf('=');
|
|
15
|
-
if (eqIdx === -1) continue;
|
|
16
|
-
const key = trimmed.slice(0, eqIdx).trim();
|
|
17
|
-
const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
|
|
18
|
-
if (!process.env[key]) process.env[key] = val; // Don't override existing env
|
|
19
|
-
}
|
|
20
|
-
} catch { /* No .env file — that's fine */ }
|
|
21
|
-
import { EngramStore } from './storage/sqlite.js';
|
|
22
|
-
import { ActivationEngine } from './engine/activation.js';
|
|
23
|
-
import { ConnectionEngine } from './engine/connections.js';
|
|
24
|
-
import { StagingBuffer } from './engine/staging.js';
|
|
25
|
-
import { EvictionEngine } from './engine/eviction.js';
|
|
26
|
-
import { RetractionEngine } from './engine/retraction.js';
|
|
27
|
-
import { EvalEngine } from './engine/eval.js';
|
|
28
|
-
import { ConsolidationEngine } from './engine/consolidation.js';
|
|
29
|
-
import { ConsolidationScheduler } from './engine/consolidation-scheduler.js';
|
|
30
|
-
import { registerRoutes } from './api/routes.js';
|
|
31
|
-
import { DEFAULT_AGENT_CONFIG } from './types/agent.js';
|
|
32
|
-
import { getEmbedder } from './core/embeddings.js';
|
|
33
|
-
import { getReranker } from './core/reranker.js';
|
|
34
|
-
import { getExpander } from './core/query-expander.js';
|
|
35
|
-
import { initLogger } from './core/logger.js';
|
|
36
|
-
|
|
37
|
-
const PORT = parseInt(process.env.AWM_PORT ?? '8400', 10);
|
|
38
|
-
const DB_PATH = process.env.AWM_DB_PATH ?? 'memory.db';
|
|
39
|
-
const API_KEY = process.env.AWM_API_KEY ?? null;
|
|
40
|
-
|
|
41
|
-
async function main() {
|
|
42
|
-
// Auto-backup: copy DB to backups/ on startup (cheap insurance)
|
|
43
|
-
if (existsSync(DB_PATH)) {
|
|
44
|
-
const dbDir = dirname(resolve(DB_PATH));
|
|
45
|
-
const backupDir = resolve(dbDir, 'backups');
|
|
46
|
-
mkdirSync(backupDir, { recursive: true });
|
|
47
|
-
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
48
|
-
const backupPath = resolve(backupDir, `${basename(DB_PATH, '.db')}-${ts}.db`);
|
|
49
|
-
try {
|
|
50
|
-
copyFileSync(resolve(DB_PATH), backupPath);
|
|
51
|
-
console.log(`Backup: ${backupPath}`);
|
|
52
|
-
} catch (err) {
|
|
53
|
-
console.log(`Backup skipped: ${(err as Error).message}`);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Logger — write activity to awm.log alongside the DB
|
|
58
|
-
initLogger(DB_PATH);
|
|
59
|
-
|
|
60
|
-
// Storage
|
|
61
|
-
const store = new EngramStore(DB_PATH);
|
|
62
|
-
|
|
63
|
-
// Integrity check
|
|
64
|
-
const integrity = store.integrityCheck();
|
|
65
|
-
if (!integrity.ok) {
|
|
66
|
-
console.error(`DB integrity check FAILED: ${integrity.result}`);
|
|
67
|
-
// Close corrupt DB, restore from backup, and exit for process manager to restart
|
|
68
|
-
store.close();
|
|
69
|
-
const dbDir = dirname(resolve(DB_PATH));
|
|
70
|
-
const backupDir = resolve(dbDir, 'backups');
|
|
71
|
-
if (existsSync(backupDir)) {
|
|
72
|
-
const backups = readdirSync(backupDir)
|
|
73
|
-
.filter(f => f.endsWith('.db'))
|
|
74
|
-
.sort()
|
|
75
|
-
.reverse();
|
|
76
|
-
if (backups.length > 0) {
|
|
77
|
-
const restorePath = resolve(backupDir, backups[0]);
|
|
78
|
-
console.error(`Attempting restore from: ${restorePath}`);
|
|
79
|
-
try {
|
|
80
|
-
copyFileSync(restorePath, resolve(DB_PATH));
|
|
81
|
-
console.error('Restore complete — exiting for restart with restored DB');
|
|
82
|
-
process.exit(1);
|
|
83
|
-
} catch (restoreErr) {
|
|
84
|
-
console.error(`Restore failed: ${(restoreErr as Error).message}`);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
console.error('No backup available — continuing with potentially corrupt DB');
|
|
89
|
-
} else {
|
|
90
|
-
console.log(' DB integrity check: ok');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Engines
|
|
94
|
-
const activationEngine = new ActivationEngine(store);
|
|
95
|
-
const connectionEngine = new ConnectionEngine(store, activationEngine);
|
|
96
|
-
const stagingBuffer = new StagingBuffer(store, activationEngine);
|
|
97
|
-
const evictionEngine = new EvictionEngine(store);
|
|
98
|
-
const retractionEngine = new RetractionEngine(store);
|
|
99
|
-
const evalEngine = new EvalEngine(store);
|
|
100
|
-
const consolidationEngine = new ConsolidationEngine(store);
|
|
101
|
-
const consolidationScheduler = new ConsolidationScheduler(store, consolidationEngine);
|
|
102
|
-
|
|
103
|
-
// API — disable Fastify's default request logging (too noisy for hive polling)
|
|
104
|
-
// bodyLimit: 512KB to prevent Content-Length mismatch errors with large task payloads
|
|
105
|
-
const app = Fastify({ logger: false, bodyLimit: 512_000 });
|
|
106
|
-
|
|
107
|
-
// Bearer token auth — only enforced when AWM_API_KEY is explicitly set and non-empty
|
|
108
|
-
if (API_KEY && API_KEY !== 'NONE' && API_KEY.length > 1) {
|
|
109
|
-
app.addHook('onRequest', async (req, reply) => {
|
|
110
|
-
if (req.url === '/health') return; // Health check is always public
|
|
111
|
-
const bearer = req.headers.authorization;
|
|
112
|
-
const xApiKey = req.headers['x-api-key'] as string | undefined;
|
|
113
|
-
if (bearer === `Bearer ${API_KEY}` || xApiKey === API_KEY) return;
|
|
114
|
-
reply.code(401).send({ error: 'Unauthorized' });
|
|
115
|
-
});
|
|
116
|
-
console.log('API key auth enabled (AWM_API_KEY set)');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
registerRoutes(app, {
|
|
120
|
-
store, activationEngine, connectionEngine,
|
|
121
|
-
evictionEngine, retractionEngine, evalEngine,
|
|
122
|
-
consolidationEngine, consolidationScheduler,
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
// Coordination module (opt-in via AWM_COORDINATION=true)
|
|
126
|
-
const { isCoordinationEnabled, initCoordination, stopCoordinationCleanup } = await import('./coordination/index.js');
|
|
127
|
-
if (isCoordinationEnabled()) {
|
|
128
|
-
initCoordination(app, store.getDb(), store);
|
|
129
|
-
} else {
|
|
130
|
-
console.log(' Coordination module disabled (set AWM_COORDINATION=true to enable)');
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Background tasks
|
|
134
|
-
stagingBuffer.start(DEFAULT_AGENT_CONFIG.stagingTtlMs);
|
|
135
|
-
consolidationScheduler.start();
|
|
136
|
-
|
|
137
|
-
// Periodic hot backup every 10 minutes (keep last 6 = 1hr coverage)
|
|
138
|
-
const dbDir = dirname(resolve(DB_PATH));
|
|
139
|
-
const backupDir = resolve(dbDir, 'backups');
|
|
140
|
-
mkdirSync(backupDir, { recursive: true });
|
|
141
|
-
|
|
142
|
-
// Cleanup old backups on startup (older than 2 hours)
|
|
143
|
-
try {
|
|
144
|
-
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
|
|
145
|
-
const now = Date.now();
|
|
146
|
-
for (const f of readdirSync(backupDir).filter(f => f.endsWith('.db'))) {
|
|
147
|
-
const match = f.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/);
|
|
148
|
-
if (match) {
|
|
149
|
-
const fileDate = new Date(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z`);
|
|
150
|
-
if (now - fileDate.getTime() > TWO_HOURS_MS) {
|
|
151
|
-
unlinkSync(resolve(backupDir, f));
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
} catch { /* cleanup is non-fatal */ }
|
|
156
|
-
|
|
157
|
-
const backupTimer = setInterval(() => {
|
|
158
|
-
try {
|
|
159
|
-
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
160
|
-
const backupPath = resolve(backupDir, `${basename(DB_PATH, '.db')}-${ts}.db`);
|
|
161
|
-
store.backup(backupPath);
|
|
162
|
-
// Prune: keep only last 6 backups
|
|
163
|
-
const backups = readdirSync(backupDir).filter(f => f.endsWith('.db')).sort();
|
|
164
|
-
while (backups.length > 6) {
|
|
165
|
-
const old = backups.shift()!;
|
|
166
|
-
try { unlinkSync(resolve(backupDir, old)); } catch { /* non-fatal */ }
|
|
167
|
-
}
|
|
168
|
-
} catch (err) {
|
|
169
|
-
console.warn(`[backup] failed: ${(err as Error).message}`);
|
|
170
|
-
}
|
|
171
|
-
}, 10 * 60_000); // 10 minutes
|
|
172
|
-
|
|
173
|
-
// Pre-load ML models (downloads on first run: embeddings ~22MB, reranker ~22MB, expander ~80MB)
|
|
174
|
-
getEmbedder().catch(err => console.warn('Embedding model unavailable:', err.message));
|
|
175
|
-
getReranker().catch(err => console.warn('Reranker model unavailable:', err.message));
|
|
176
|
-
getExpander().catch(err => console.warn('Query expander model unavailable:', err.message));
|
|
177
|
-
|
|
178
|
-
// Start server
|
|
179
|
-
await app.listen({ port: PORT, host: '0.0.0.0' });
|
|
180
|
-
console.log(`AgentWorkingMemory v0.7.
|
|
181
|
-
|
|
182
|
-
// Graceful shutdown
|
|
183
|
-
const shutdown = async () => {
|
|
184
|
-
clearInterval(backupTimer);
|
|
185
|
-
await stopCoordinationCleanup();
|
|
186
|
-
consolidationScheduler.stop();
|
|
187
|
-
stagingBuffer.stop();
|
|
188
|
-
try { store.walCheckpoint(); } catch { /* non-fatal */ }
|
|
189
|
-
store.close();
|
|
190
|
-
process.exit(0);
|
|
191
|
-
};
|
|
192
|
-
process.on('SIGINT', shutdown);
|
|
193
|
-
process.on('SIGTERM', shutdown);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
main().catch(err => {
|
|
197
|
-
console.error('Failed to start:', err);
|
|
198
|
-
process.exit(1);
|
|
199
|
-
});
|
|
1
|
+
// Copyright 2026 Robert Winter / Complete Ideas
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { readFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { resolve, dirname, basename } from 'node:path';
|
|
5
|
+
import Fastify from 'fastify';
|
|
6
|
+
|
|
7
|
+
// Load .env file if present (no external dependency)
|
|
8
|
+
try {
|
|
9
|
+
const envPath = resolve(process.cwd(), '.env');
|
|
10
|
+
const envContent = readFileSync(envPath, 'utf-8');
|
|
11
|
+
for (const line of envContent.split('\n')) {
|
|
12
|
+
const trimmed = line.trim();
|
|
13
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
14
|
+
const eqIdx = trimmed.indexOf('=');
|
|
15
|
+
if (eqIdx === -1) continue;
|
|
16
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
17
|
+
const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
|
|
18
|
+
if (!process.env[key]) process.env[key] = val; // Don't override existing env
|
|
19
|
+
}
|
|
20
|
+
} catch { /* No .env file — that's fine */ }
|
|
21
|
+
import { EngramStore } from './storage/sqlite.js';
|
|
22
|
+
import { ActivationEngine } from './engine/activation.js';
|
|
23
|
+
import { ConnectionEngine } from './engine/connections.js';
|
|
24
|
+
import { StagingBuffer } from './engine/staging.js';
|
|
25
|
+
import { EvictionEngine } from './engine/eviction.js';
|
|
26
|
+
import { RetractionEngine } from './engine/retraction.js';
|
|
27
|
+
import { EvalEngine } from './engine/eval.js';
|
|
28
|
+
import { ConsolidationEngine } from './engine/consolidation.js';
|
|
29
|
+
import { ConsolidationScheduler } from './engine/consolidation-scheduler.js';
|
|
30
|
+
import { registerRoutes } from './api/routes.js';
|
|
31
|
+
import { DEFAULT_AGENT_CONFIG } from './types/agent.js';
|
|
32
|
+
import { getEmbedder } from './core/embeddings.js';
|
|
33
|
+
import { getReranker } from './core/reranker.js';
|
|
34
|
+
import { getExpander } from './core/query-expander.js';
|
|
35
|
+
import { initLogger } from './core/logger.js';
|
|
36
|
+
|
|
37
|
+
const PORT = parseInt(process.env.AWM_PORT ?? '8400', 10);
|
|
38
|
+
const DB_PATH = process.env.AWM_DB_PATH ?? 'memory.db';
|
|
39
|
+
const API_KEY = process.env.AWM_API_KEY ?? null;
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
// Auto-backup: copy DB to backups/ on startup (cheap insurance)
|
|
43
|
+
if (existsSync(DB_PATH)) {
|
|
44
|
+
const dbDir = dirname(resolve(DB_PATH));
|
|
45
|
+
const backupDir = resolve(dbDir, 'backups');
|
|
46
|
+
mkdirSync(backupDir, { recursive: true });
|
|
47
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
48
|
+
const backupPath = resolve(backupDir, `${basename(DB_PATH, '.db')}-${ts}.db`);
|
|
49
|
+
try {
|
|
50
|
+
copyFileSync(resolve(DB_PATH), backupPath);
|
|
51
|
+
console.log(`Backup: ${backupPath}`);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.log(`Backup skipped: ${(err as Error).message}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Logger — write activity to awm.log alongside the DB
|
|
58
|
+
initLogger(DB_PATH);
|
|
59
|
+
|
|
60
|
+
// Storage
|
|
61
|
+
const store = new EngramStore(DB_PATH);
|
|
62
|
+
|
|
63
|
+
// Integrity check
|
|
64
|
+
const integrity = store.integrityCheck();
|
|
65
|
+
if (!integrity.ok) {
|
|
66
|
+
console.error(`DB integrity check FAILED: ${integrity.result}`);
|
|
67
|
+
// Close corrupt DB, restore from backup, and exit for process manager to restart
|
|
68
|
+
store.close();
|
|
69
|
+
const dbDir = dirname(resolve(DB_PATH));
|
|
70
|
+
const backupDir = resolve(dbDir, 'backups');
|
|
71
|
+
if (existsSync(backupDir)) {
|
|
72
|
+
const backups = readdirSync(backupDir)
|
|
73
|
+
.filter(f => f.endsWith('.db'))
|
|
74
|
+
.sort()
|
|
75
|
+
.reverse();
|
|
76
|
+
if (backups.length > 0) {
|
|
77
|
+
const restorePath = resolve(backupDir, backups[0]);
|
|
78
|
+
console.error(`Attempting restore from: ${restorePath}`);
|
|
79
|
+
try {
|
|
80
|
+
copyFileSync(restorePath, resolve(DB_PATH));
|
|
81
|
+
console.error('Restore complete — exiting for restart with restored DB');
|
|
82
|
+
process.exit(1);
|
|
83
|
+
} catch (restoreErr) {
|
|
84
|
+
console.error(`Restore failed: ${(restoreErr as Error).message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
console.error('No backup available — continuing with potentially corrupt DB');
|
|
89
|
+
} else {
|
|
90
|
+
console.log(' DB integrity check: ok');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Engines
|
|
94
|
+
const activationEngine = new ActivationEngine(store);
|
|
95
|
+
const connectionEngine = new ConnectionEngine(store, activationEngine);
|
|
96
|
+
const stagingBuffer = new StagingBuffer(store, activationEngine);
|
|
97
|
+
const evictionEngine = new EvictionEngine(store);
|
|
98
|
+
const retractionEngine = new RetractionEngine(store);
|
|
99
|
+
const evalEngine = new EvalEngine(store);
|
|
100
|
+
const consolidationEngine = new ConsolidationEngine(store);
|
|
101
|
+
const consolidationScheduler = new ConsolidationScheduler(store, consolidationEngine);
|
|
102
|
+
|
|
103
|
+
// API — disable Fastify's default request logging (too noisy for hive polling)
|
|
104
|
+
// bodyLimit: 512KB to prevent Content-Length mismatch errors with large task payloads
|
|
105
|
+
const app = Fastify({ logger: false, bodyLimit: 512_000 });
|
|
106
|
+
|
|
107
|
+
// Bearer token auth — only enforced when AWM_API_KEY is explicitly set and non-empty
|
|
108
|
+
if (API_KEY && API_KEY !== 'NONE' && API_KEY.length > 1) {
|
|
109
|
+
app.addHook('onRequest', async (req, reply) => {
|
|
110
|
+
if (req.url === '/health') return; // Health check is always public
|
|
111
|
+
const bearer = req.headers.authorization;
|
|
112
|
+
const xApiKey = req.headers['x-api-key'] as string | undefined;
|
|
113
|
+
if (bearer === `Bearer ${API_KEY}` || xApiKey === API_KEY) return;
|
|
114
|
+
reply.code(401).send({ error: 'Unauthorized' });
|
|
115
|
+
});
|
|
116
|
+
console.log('API key auth enabled (AWM_API_KEY set)');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
registerRoutes(app, {
|
|
120
|
+
store, activationEngine, connectionEngine,
|
|
121
|
+
evictionEngine, retractionEngine, evalEngine,
|
|
122
|
+
consolidationEngine, consolidationScheduler,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Coordination module (opt-in via AWM_COORDINATION=true)
|
|
126
|
+
const { isCoordinationEnabled, initCoordination, stopCoordinationCleanup } = await import('./coordination/index.js');
|
|
127
|
+
if (isCoordinationEnabled()) {
|
|
128
|
+
initCoordination(app, store.getDb(), store);
|
|
129
|
+
} else {
|
|
130
|
+
console.log(' Coordination module disabled (set AWM_COORDINATION=true to enable)');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Background tasks
|
|
134
|
+
stagingBuffer.start(DEFAULT_AGENT_CONFIG.stagingTtlMs);
|
|
135
|
+
consolidationScheduler.start();
|
|
136
|
+
|
|
137
|
+
// Periodic hot backup every 10 minutes (keep last 6 = 1hr coverage)
|
|
138
|
+
const dbDir = dirname(resolve(DB_PATH));
|
|
139
|
+
const backupDir = resolve(dbDir, 'backups');
|
|
140
|
+
mkdirSync(backupDir, { recursive: true });
|
|
141
|
+
|
|
142
|
+
// Cleanup old backups on startup (older than 2 hours)
|
|
143
|
+
try {
|
|
144
|
+
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
for (const f of readdirSync(backupDir).filter(f => f.endsWith('.db'))) {
|
|
147
|
+
const match = f.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/);
|
|
148
|
+
if (match) {
|
|
149
|
+
const fileDate = new Date(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z`);
|
|
150
|
+
if (now - fileDate.getTime() > TWO_HOURS_MS) {
|
|
151
|
+
unlinkSync(resolve(backupDir, f));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch { /* cleanup is non-fatal */ }
|
|
156
|
+
|
|
157
|
+
const backupTimer = setInterval(() => {
|
|
158
|
+
try {
|
|
159
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
160
|
+
const backupPath = resolve(backupDir, `${basename(DB_PATH, '.db')}-${ts}.db`);
|
|
161
|
+
store.backup(backupPath);
|
|
162
|
+
// Prune: keep only last 6 backups
|
|
163
|
+
const backups = readdirSync(backupDir).filter(f => f.endsWith('.db')).sort();
|
|
164
|
+
while (backups.length > 6) {
|
|
165
|
+
const old = backups.shift()!;
|
|
166
|
+
try { unlinkSync(resolve(backupDir, old)); } catch { /* non-fatal */ }
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.warn(`[backup] failed: ${(err as Error).message}`);
|
|
170
|
+
}
|
|
171
|
+
}, 10 * 60_000); // 10 minutes
|
|
172
|
+
|
|
173
|
+
// Pre-load ML models (downloads on first run: embeddings ~22MB, reranker ~22MB, expander ~80MB)
|
|
174
|
+
getEmbedder().catch(err => console.warn('Embedding model unavailable:', err.message));
|
|
175
|
+
getReranker().catch(err => console.warn('Reranker model unavailable:', err.message));
|
|
176
|
+
getExpander().catch(err => console.warn('Query expander model unavailable:', err.message));
|
|
177
|
+
|
|
178
|
+
// Start server
|
|
179
|
+
await app.listen({ port: PORT, host: '0.0.0.0' });
|
|
180
|
+
console.log(`AgentWorkingMemory v0.7.6 listening on port ${PORT}`);
|
|
181
|
+
|
|
182
|
+
// Graceful shutdown
|
|
183
|
+
const shutdown = async () => {
|
|
184
|
+
clearInterval(backupTimer);
|
|
185
|
+
await stopCoordinationCleanup();
|
|
186
|
+
consolidationScheduler.stop();
|
|
187
|
+
stagingBuffer.stop();
|
|
188
|
+
try { store.walCheckpoint(); } catch { /* non-fatal */ }
|
|
189
|
+
store.close();
|
|
190
|
+
process.exit(0);
|
|
191
|
+
};
|
|
192
|
+
process.on('SIGINT', shutdown);
|
|
193
|
+
process.on('SIGTERM', shutdown);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
main().catch(err => {
|
|
197
|
+
console.error('Failed to start:', err);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
});
|