@awareness-sdk/local 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI entry point for Awareness Local daemon.
5
+ *
6
+ * Subcommands:
7
+ * start [--project <dir>] [--port <port>] [--foreground] — start daemon
8
+ * stop [--project <dir>] — stop daemon
9
+ * status [--project <dir>] — show daemon status + stats
10
+ * reindex [--project <dir>] — rebuild FTS5 + embedding index
11
+ *
12
+ * Uses process.argv parsing (no dependencies).
13
+ * For `start` without `--foreground`, spawns self as a detached child process.
14
+ */
15
+
16
+ import { spawn } from 'node:child_process';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import http from 'node:http';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Argv parsing
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Parse CLI arguments into { command, flags }.
28
+ * Supports: --flag value, --flag=value, --boolean-flag
29
+ * @param {string[]} argv — typically process.argv.slice(2)
30
+ * @returns {{ command: string, flags: Record<string, string|boolean> }}
31
+ */
32
+ function parseArgs(argv) {
33
+ const positional = [];
34
+ const flags = {};
35
+ let i = 0;
36
+
37
+ while (i < argv.length) {
38
+ const arg = argv[i];
39
+
40
+ if (arg.startsWith('--')) {
41
+ const eqIdx = arg.indexOf('=');
42
+ if (eqIdx !== -1) {
43
+ // --key=value
44
+ const key = arg.slice(2, eqIdx);
45
+ flags[key] = arg.slice(eqIdx + 1);
46
+ } else {
47
+ const key = arg.slice(2);
48
+ const next = argv[i + 1];
49
+ if (next && !next.startsWith('--')) {
50
+ flags[key] = next;
51
+ i++;
52
+ } else {
53
+ flags[key] = true;
54
+ }
55
+ }
56
+ } else {
57
+ positional.push(arg);
58
+ }
59
+ i++;
60
+ }
61
+
62
+ return {
63
+ command: positional[0] || 'start',
64
+ flags,
65
+ };
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Helpers
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const AWARENESS_DIR = '.awareness';
73
+ const PID_FILENAME = 'daemon.pid';
74
+ const LOG_FILENAME = 'daemon.log';
75
+
76
+ /**
77
+ * Resolve the project directory from flags or cwd.
78
+ * @param {Record<string, string|boolean>} flags
79
+ * @returns {string}
80
+ */
81
+ function resolveProjectDir(flags) {
82
+ const dir = typeof flags.project === 'string' ? flags.project : process.cwd();
83
+ return path.resolve(dir);
84
+ }
85
+
86
+ /**
87
+ * Resolve the daemon port from flags or default.
88
+ * @param {Record<string, string|boolean>} flags
89
+ * @returns {number}
90
+ */
91
+ function resolvePort(flags) {
92
+ if (typeof flags.port === 'string') {
93
+ const p = parseInt(flags.port, 10);
94
+ if (!isNaN(p) && p > 0 && p < 65536) return p;
95
+ }
96
+ return 37800;
97
+ }
98
+
99
+ /**
100
+ * HTTP GET to localhost — returns response body as string or null on error.
101
+ * @param {number} port
102
+ * @param {string} urlPath
103
+ * @param {number} [timeoutMs=3000]
104
+ * @returns {Promise<{ status: number, body: string }|null>}
105
+ */
106
+ function httpGet(port, urlPath, timeoutMs = 3000) {
107
+ return new Promise((resolve) => {
108
+ const req = http.get(
109
+ { hostname: '127.0.0.1', port, path: urlPath, timeout: timeoutMs },
110
+ (res) => {
111
+ const chunks = [];
112
+ res.on('data', (c) => chunks.push(c));
113
+ res.on('end', () => {
114
+ resolve({
115
+ status: res.statusCode,
116
+ body: Buffer.concat(chunks).toString('utf-8'),
117
+ });
118
+ });
119
+ }
120
+ );
121
+ req.on('error', () => resolve(null));
122
+ req.on('timeout', () => {
123
+ req.destroy();
124
+ resolve(null);
125
+ });
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Read PID from .awareness/daemon.pid.
131
+ * @param {string} projectDir
132
+ * @returns {number|null}
133
+ */
134
+ function readPid(projectDir) {
135
+ const pidPath = path.join(projectDir, AWARENESS_DIR, PID_FILENAME);
136
+ try {
137
+ const content = fs.readFileSync(pidPath, 'utf-8').trim();
138
+ const pid = parseInt(content, 10);
139
+ return isNaN(pid) ? null : pid;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Check if a process with the given PID exists.
147
+ * @param {number} pid
148
+ * @returns {boolean}
149
+ */
150
+ function processExists(pid) {
151
+ try {
152
+ process.kill(pid, 0);
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Commands
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /**
164
+ * Start the daemon.
165
+ * Without --foreground: spawns a new detached process with --foreground flag.
166
+ * With --foreground: imports and runs the daemon in-process.
167
+ */
168
+ async function cmdStart(flags) {
169
+ const projectDir = resolveProjectDir(flags);
170
+ const port = resolvePort(flags);
171
+ const foreground = flags.foreground === true;
172
+
173
+ // Ensure .awareness directory exists
174
+ const awarenessDir = path.join(projectDir, AWARENESS_DIR);
175
+ fs.mkdirSync(awarenessDir, { recursive: true });
176
+
177
+ // Check if already running
178
+ const pid = readPid(projectDir);
179
+ if (pid && processExists(pid)) {
180
+ const resp = await httpGet(port, '/healthz');
181
+ if (resp && resp.status === 200) {
182
+ console.log(`Awareness Local daemon already running (PID ${pid}, port ${port})`);
183
+ process.exit(0);
184
+ }
185
+ }
186
+
187
+ if (foreground) {
188
+ // Run in foreground — import daemon and start
189
+ const { AwarenessLocalDaemon } = await import('../src/daemon.mjs');
190
+ const daemon = new AwarenessLocalDaemon({ port, projectDir });
191
+
192
+ // Handle termination signals
193
+ const shutdown = async () => {
194
+ console.log('\n[awareness-local] shutting down...');
195
+ await daemon.stop();
196
+ process.exit(0);
197
+ };
198
+ process.on('SIGINT', shutdown);
199
+ process.on('SIGTERM', shutdown);
200
+
201
+ await daemon.start();
202
+ } else {
203
+ // Background mode: spawn self with --foreground
204
+ const thisFile = fileURLToPath(import.meta.url);
205
+ const logPath = path.join(awarenessDir, LOG_FILENAME);
206
+ const logFd = fs.openSync(logPath, 'a');
207
+
208
+ const child = spawn(
209
+ process.execPath,
210
+ [thisFile, 'start', '--foreground', '--project', projectDir, '--port', String(port)],
211
+ {
212
+ detached: true,
213
+ stdio: ['ignore', logFd, logFd],
214
+ cwd: projectDir,
215
+ env: { ...process.env },
216
+ }
217
+ );
218
+
219
+ child.unref();
220
+ fs.closeSync(logFd);
221
+
222
+ // Wait for daemon to become healthy (up to 15 seconds)
223
+ console.log('Starting Awareness Local daemon...');
224
+ let healthy = false;
225
+ for (let i = 0; i < 30; i++) {
226
+ await new Promise((r) => setTimeout(r, 500));
227
+ const resp = await httpGet(port, '/healthz');
228
+ if (resp && resp.status === 200) {
229
+ healthy = true;
230
+ break;
231
+ }
232
+ }
233
+
234
+ if (healthy) {
235
+ const newPid = readPid(projectDir);
236
+ console.log(`Awareness Local daemon started (PID ${newPid || child.pid}, port ${port})`);
237
+ console.log(` MCP endpoint: http://localhost:${port}/mcp`);
238
+ console.log(` Dashboard: http://localhost:${port}/`);
239
+ console.log(` Log file: ${logPath}`);
240
+ } else {
241
+ console.error('Failed to start daemon. Check log file:');
242
+ console.error(` ${logPath}`);
243
+ process.exit(1);
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Stop the daemon.
250
+ */
251
+ async function cmdStop(flags) {
252
+ const projectDir = resolveProjectDir(flags);
253
+ const pid = readPid(projectDir);
254
+
255
+ if (!pid) {
256
+ console.log('Awareness Local daemon is not running (no PID file found)');
257
+ process.exit(0);
258
+ }
259
+
260
+ if (!processExists(pid)) {
261
+ // Stale PID file — clean up
262
+ const pidPath = path.join(projectDir, AWARENESS_DIR, PID_FILENAME);
263
+ try { fs.unlinkSync(pidPath); } catch { /* ignore */ }
264
+ console.log('Awareness Local daemon is not running (stale PID file removed)');
265
+ process.exit(0);
266
+ }
267
+
268
+ // Send SIGTERM
269
+ try {
270
+ process.kill(pid, 'SIGTERM');
271
+ } catch (err) {
272
+ console.error(`Failed to stop daemon (PID ${pid}): ${err.message}`);
273
+ process.exit(1);
274
+ }
275
+
276
+ // Wait for process to exit (up to 5 seconds)
277
+ for (let i = 0; i < 10; i++) {
278
+ await new Promise((r) => setTimeout(r, 500));
279
+ if (!processExists(pid)) break;
280
+ }
281
+
282
+ // Force kill if still alive
283
+ if (processExists(pid)) {
284
+ try {
285
+ process.kill(pid, 'SIGKILL');
286
+ } catch {
287
+ // ignore
288
+ }
289
+ }
290
+
291
+ // Clean PID file
292
+ const pidPath = path.join(projectDir, AWARENESS_DIR, PID_FILENAME);
293
+ try { fs.unlinkSync(pidPath); } catch { /* ignore */ }
294
+
295
+ console.log(`Awareness Local daemon stopped (was PID ${pid})`);
296
+ }
297
+
298
+ /**
299
+ * Show daemon status and stats.
300
+ */
301
+ async function cmdStatus(flags) {
302
+ const projectDir = resolveProjectDir(flags);
303
+ const port = resolvePort(flags);
304
+ const pid = readPid(projectDir);
305
+
306
+ if (!pid || !processExists(pid)) {
307
+ console.log('Awareness Local: not running');
308
+ if (pid) {
309
+ // Clean stale PID file
310
+ const pidPath = path.join(projectDir, AWARENESS_DIR, PID_FILENAME);
311
+ try { fs.unlinkSync(pidPath); } catch { /* ignore */ }
312
+ }
313
+ process.exit(0);
314
+ }
315
+
316
+ // Fetch health info
317
+ const resp = await httpGet(port, '/healthz');
318
+ if (!resp || resp.status !== 200) {
319
+ console.log(`Awareness Local: PID ${pid} exists but HTTP not responding on port ${port}`);
320
+ process.exit(1);
321
+ }
322
+
323
+ try {
324
+ const data = JSON.parse(resp.body);
325
+ const uptime = data.uptime || 0;
326
+ const hours = Math.floor(uptime / 3600);
327
+ const minutes = Math.floor((uptime % 3600) / 60);
328
+ const uptimeStr = hours > 0
329
+ ? `${hours}h ${minutes}m`
330
+ : `${minutes}m ${uptime % 60}s`;
331
+
332
+ console.log(`Awareness Local: running (PID ${pid}, port ${port})`);
333
+ console.log(` Uptime: ${uptimeStr}`);
334
+ console.log(` Project: ${data.project_dir || projectDir}`);
335
+
336
+ if (data.stats) {
337
+ const s = data.stats;
338
+ console.log(` Memories: ${s.totalMemories || 0}`);
339
+ console.log(` Knowledge Cards: ${s.totalKnowledge || 0}`);
340
+ console.log(` Open Tasks: ${s.totalTasks || 0}`);
341
+ console.log(` Sessions: ${s.totalSessions || 0}`);
342
+ }
343
+
344
+ // Check cloud sync status
345
+ const awarenessDir = path.join(projectDir, AWARENESS_DIR);
346
+ const configPath = path.join(awarenessDir, 'config.json');
347
+ if (fs.existsSync(configPath)) {
348
+ try {
349
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
350
+ if (config.cloud?.enabled) {
351
+ console.log(` Cloud Sync: enabled (${config.cloud.api_base || 'awareness.market'})`);
352
+ } else {
353
+ console.log(' Cloud Sync: not configured');
354
+ }
355
+ } catch {
356
+ console.log(' Cloud Sync: unknown');
357
+ }
358
+ } else {
359
+ console.log(' Cloud Sync: not configured');
360
+ }
361
+ } catch {
362
+ console.log(`Awareness Local: running (PID ${pid})`);
363
+ console.log(` Raw response: ${resp.body}`);
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Rebuild the FTS5 + embedding index.
369
+ */
370
+ async function cmdReindex(flags) {
371
+ const projectDir = resolveProjectDir(flags);
372
+ const port = resolvePort(flags);
373
+
374
+ // Check if daemon is running — if so, it holds a lock on index.db
375
+ const pid = readPid(projectDir);
376
+ const daemonRunning = pid && processExists(pid);
377
+
378
+ if (daemonRunning) {
379
+ console.log('Daemon is running — stopping it first for safe reindex...');
380
+ await cmdStop(flags);
381
+ // Brief pause for SQLite lock release
382
+ await new Promise((r) => setTimeout(r, 1000));
383
+ }
384
+
385
+ console.log('Rebuilding index...');
386
+
387
+ const awarenessDir = path.join(projectDir, AWARENESS_DIR);
388
+ const dbPath = path.join(awarenessDir, 'index.db');
389
+
390
+ // Remove existing database to force full rebuild
391
+ for (const ext of ['', '-journal', '-wal', '-shm']) {
392
+ const p = dbPath + ext;
393
+ if (fs.existsSync(p)) {
394
+ fs.unlinkSync(p);
395
+ console.log(` Removed: ${path.basename(p)}`);
396
+ }
397
+ }
398
+
399
+ // Import and run indexer
400
+ try {
401
+ const { Indexer } = await import('../src/core/indexer.mjs');
402
+ const { MemoryStore } = await import('../src/core/memory-store.mjs');
403
+
404
+ const store = new MemoryStore(projectDir);
405
+ const indexer = new Indexer(dbPath);
406
+
407
+ const result = await indexer.incrementalIndex(store);
408
+ console.log(`Reindex complete: ${result.indexed} files indexed, ${result.skipped} skipped`);
409
+
410
+ indexer.close();
411
+ } catch (err) {
412
+ console.error(`Reindex failed: ${err.message}`);
413
+ process.exit(1);
414
+ }
415
+
416
+ // Restart daemon if it was running
417
+ if (daemonRunning) {
418
+ console.log('Restarting daemon...');
419
+ await cmdStart({ ...flags, foreground: undefined });
420
+ }
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Help
425
+ // ---------------------------------------------------------------------------
426
+
427
+ function printHelp() {
428
+ console.log(`
429
+ Awareness Local — AI agent memory daemon
430
+
431
+ Usage:
432
+ awareness-local <command> [options]
433
+
434
+ Commands:
435
+ start Start the daemon (default)
436
+ stop Stop the daemon
437
+ status Show daemon status and stats
438
+ reindex Rebuild the search index
439
+
440
+ Options:
441
+ --project <dir> Project directory (default: current directory)
442
+ --port <port> HTTP port (default: 37800)
443
+ --foreground Run in foreground (don't detach)
444
+ --help Show this help message
445
+
446
+ Examples:
447
+ npx @awareness-sdk/local start
448
+ npx @awareness-sdk/local status
449
+ npx @awareness-sdk/local stop
450
+ npx @awareness-sdk/local reindex --project /path/to/project
451
+ `);
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // Main
456
+ // ---------------------------------------------------------------------------
457
+
458
+ async function main() {
459
+ const { command, flags } = parseArgs(process.argv.slice(2));
460
+
461
+ if (flags.help || command === 'help') {
462
+ printHelp();
463
+ process.exit(0);
464
+ }
465
+
466
+ switch (command) {
467
+ case 'start':
468
+ await cmdStart(flags);
469
+ break;
470
+ case 'stop':
471
+ await cmdStop(flags);
472
+ break;
473
+ case 'status':
474
+ await cmdStatus(flags);
475
+ break;
476
+ case 'reindex':
477
+ await cmdReindex(flags);
478
+ break;
479
+ default:
480
+ console.error(`Unknown command: ${command}`);
481
+ printHelp();
482
+ process.exit(1);
483
+ }
484
+ }
485
+
486
+ main().catch((err) => {
487
+ console.error(`Fatal error: ${err.message}`);
488
+ process.exit(1);
489
+ });
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@awareness-sdk/local",
3
+ "version": "0.1.0",
4
+ "description": "Local-first AI agent memory system. No account needed.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "awareness-local": "./bin/awareness-local.mjs"
9
+ },
10
+ "main": "./src/api.mjs",
11
+ "exports": {
12
+ ".": "./src/api.mjs",
13
+ "./api": "./src/api.mjs"
14
+ },
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "dependencies": {
19
+ "better-sqlite3": "^11.0.0",
20
+ "@modelcontextprotocol/sdk": "^1.27.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/better-sqlite3": "^7.6.0"
24
+ },
25
+ "keywords": ["ai", "agent", "memory", "local", "mcp", "claude", "cursor", "windsurf", "awareness"],
26
+ "files": ["bin/", "src/", "LICENSE", "README.md"],
27
+ "scripts": {
28
+ "test": "node --test test/*.test.mjs",
29
+ "start": "node bin/awareness-local.mjs start"
30
+ }
31
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Public API for @awareness-sdk/local
3
+ *
4
+ * Exports high-level functions for external callers (setup-cli, plugins, etc.).
5
+ * This is the package's main entry point (package.json "main" / "exports").
6
+ *
7
+ * Two categories:
8
+ * 1. Re-exports from core/ — directory management & config
9
+ * 2. Daemon management — lightweight HTTP-based checks (no heavy imports)
10
+ */
11
+
12
+ import http from 'node:http';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Re-exports from core modules
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export {
19
+ ensureLocalDirs,
20
+ initLocalConfig,
21
+ loadLocalConfig,
22
+ saveCloudConfig,
23
+ getConfigPath,
24
+ generateDeviceId,
25
+ } from './core/config.mjs';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Daemon management (HTTP-based, no import of daemon internals)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Build the daemon base URL.
33
+ *
34
+ * @param {number} [port=37800]
35
+ * @returns {string} e.g. "http://localhost:37800"
36
+ */
37
+ export function getDaemonUrl(port = 37800) {
38
+ return `http://localhost:${port}`;
39
+ }
40
+
41
+ /**
42
+ * Check if the local daemon is healthy.
43
+ * Performs a GET to localhost:{port}/healthz and returns true if status 200.
44
+ *
45
+ * @param {number} [port=37800]
46
+ * @param {number} [timeoutMs=2000]
47
+ * @returns {Promise<boolean>}
48
+ */
49
+ export async function checkDaemonHealth(port = 37800, timeoutMs = 2000) {
50
+ return new Promise((resolve) => {
51
+ const req = http.get(
52
+ {
53
+ hostname: '127.0.0.1',
54
+ port,
55
+ path: '/healthz',
56
+ timeout: timeoutMs,
57
+ },
58
+ (res) => {
59
+ // Drain the response body
60
+ res.resume();
61
+ resolve(res.statusCode === 200);
62
+ }
63
+ );
64
+ req.on('error', () => resolve(false));
65
+ req.on('timeout', () => {
66
+ req.destroy();
67
+ resolve(false);
68
+ });
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Get the daemon status including stats.
74
+ * Returns the parsed /healthz JSON, or null if the daemon is not reachable.
75
+ *
76
+ * @param {number} [port=37800]
77
+ * @param {number} [timeoutMs=3000]
78
+ * @returns {Promise<object|null>} — { status, mode, version, uptime, pid, port, project_dir, stats } or null
79
+ */
80
+ export async function getDaemonStatus(port = 37800, timeoutMs = 3000) {
81
+ return new Promise((resolve) => {
82
+ const req = http.get(
83
+ {
84
+ hostname: '127.0.0.1',
85
+ port,
86
+ path: '/healthz',
87
+ timeout: timeoutMs,
88
+ },
89
+ (res) => {
90
+ const chunks = [];
91
+ res.on('data', (chunk) => chunks.push(chunk));
92
+ res.on('end', () => {
93
+ if (res.statusCode !== 200) {
94
+ resolve(null);
95
+ return;
96
+ }
97
+ try {
98
+ const body = Buffer.concat(chunks).toString('utf-8');
99
+ resolve(JSON.parse(body));
100
+ } catch {
101
+ resolve(null);
102
+ }
103
+ });
104
+ }
105
+ );
106
+ req.on('error', () => resolve(null));
107
+ req.on('timeout', () => {
108
+ req.destroy();
109
+ resolve(null);
110
+ });
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Get the MCP endpoint URL for the local daemon.
116
+ *
117
+ * @param {number} [port=37800]
118
+ * @returns {string} e.g. "http://localhost:37800/mcp"
119
+ */
120
+ export function getMcpUrl(port = 37800) {
121
+ return `http://localhost:${port}/mcp`;
122
+ }