@awareness-sdk/local 0.1.3 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awareness-sdk/local",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Local-first AI agent memory system. No account needed.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -475,6 +475,12 @@ export class CloudSync {
475
475
 
476
476
  res.on('data', (chunk) => {
477
477
  buffer += chunk;
478
+ // SECURITY C7: Cap SSE buffer to prevent unbounded memory growth
479
+ if (buffer.length > 1024 * 1024) {
480
+ console.warn(`${LOG_PREFIX} SSE buffer overflow (>1MB) — dropping`);
481
+ buffer = '';
482
+ return;
483
+ }
478
484
  const { parsed, remainder } = parseSSE(buffer);
479
485
  buffer = remainder;
480
486
 
@@ -181,7 +181,10 @@ export function initLocalConfig(projectDir) {
181
181
  config.device.id = deviceId;
182
182
  config.device.name = deviceName;
183
183
 
184
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
184
+ // SECURITY C6: Atomic write (tmp+rename) to prevent corruption on crash
185
+ const tmpPath = configPath + '.tmp';
186
+ fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), 'utf-8');
187
+ fs.renameSync(tmpPath, configPath);
185
188
  return config;
186
189
  }
187
190
 
@@ -235,9 +238,11 @@ export function saveCloudConfig(projectDir, { apiKey, memoryId, apiBase }) {
235
238
  }
236
239
 
237
240
  const configPath = getConfigPath(projectDir);
238
- // Ensure dirs exist before writing
239
241
  ensureLocalDirs(projectDir);
240
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
242
+ // SECURITY C6: Atomic write
243
+ const tmpPath = configPath + '.tmp';
244
+ fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), 'utf-8');
245
+ fs.renameSync(tmpPath, configPath);
241
246
 
242
247
  return config;
243
248
  }
@@ -283,6 +288,8 @@ function deepClone(obj) {
283
288
  */
284
289
  function deepMerge(target, source) {
285
290
  for (const key of Object.keys(source)) {
291
+ // SECURITY H7: Prevent prototype pollution
292
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
286
293
  const srcVal = source[key];
287
294
  const tgtVal = target[key];
288
295
 
@@ -634,13 +634,21 @@ export class Indexer {
634
634
  *
635
635
  * @param {number} [limit=5]
636
636
  */
637
- getOpenTasks(limit = 5) {
637
+ getOpenTasks(limit = 0) {
638
+ if (limit > 0) {
639
+ return this.db
640
+ .prepare(
641
+ `SELECT * FROM tasks WHERE status = 'open'
642
+ ORDER BY created_at DESC LIMIT ?`
643
+ )
644
+ .all(limit);
645
+ }
638
646
  return this.db
639
647
  .prepare(
640
648
  `SELECT * FROM tasks WHERE status = 'open'
641
- ORDER BY created_at DESC LIMIT ?`
649
+ ORDER BY created_at DESC`
642
650
  )
643
- .all(limit);
651
+ .all();
644
652
  }
645
653
 
646
654
  /**
@@ -154,7 +154,18 @@ export class KnowledgeExtractor {
154
154
  }
155
155
  }
156
156
 
157
- return { cards, tasks, risks };
157
+ // Collect completed_tasks for auto-completion processing
158
+ const completedTasks = [];
159
+ if (insights.completed_tasks) {
160
+ for (const ct of insights.completed_tasks) {
161
+ const taskId = (ct.task_id || '').trim();
162
+ if (taskId) {
163
+ completedTasks.push({ task_id: taskId, reason: ct.reason || '' });
164
+ }
165
+ }
166
+ }
167
+
168
+ return { cards, tasks, risks, completedTasks };
158
169
  }
159
170
 
160
171
  // -------------------------------------------------------------------------
@@ -480,6 +491,26 @@ export class KnowledgeExtractor {
480
491
  }
481
492
 
482
493
  await Promise.all(promises);
494
+
495
+ // Auto-complete tasks identified by the LLM
496
+ if (result.completedTasks && this.indexer) {
497
+ for (const ct of result.completedTasks) {
498
+ try {
499
+ const existing = this.indexer.db
500
+ .prepare('SELECT * FROM tasks WHERE id = ?')
501
+ .get(ct.task_id);
502
+ if (existing && existing.status !== 'done') {
503
+ this.indexer.indexTask({
504
+ ...existing,
505
+ status: 'done',
506
+ updated_at: new Date().toISOString(),
507
+ });
508
+ }
509
+ } catch (err) {
510
+ console.warn(`[KnowledgeExtractor] Failed to auto-complete task ${ct.task_id}:`, err.message);
511
+ }
512
+ }
513
+ }
483
514
  }
484
515
 
485
516
  /**
package/src/daemon.mjs CHANGED
@@ -50,23 +50,38 @@ function nowISO() {
50
50
  */
51
51
  function jsonResponse(res, data, status = 200) {
52
52
  const body = JSON.stringify(data);
53
+ // SECURITY: Only allow requests from localhost dashboard (not arbitrary websites)
54
+ const origin = 'http://localhost:37800';
53
55
  res.writeHead(status, {
54
56
  'Content-Type': 'application/json',
55
57
  'Content-Length': Buffer.byteLength(body),
56
- 'Access-Control-Allow-Origin': '*',
58
+ 'Access-Control-Allow-Origin': origin,
57
59
  });
58
60
  res.end(body);
59
61
  }
60
62
 
63
+ /** Max request body size (10 MB) — prevents memory exhaustion DoS. */
64
+ const MAX_BODY_BYTES = 10 * 1024 * 1024;
65
+
61
66
  /**
62
67
  * Read the full request body as a string.
68
+ * Rejects with 413 if body exceeds MAX_BODY_BYTES.
63
69
  * @param {http.IncomingMessage} req
64
70
  * @returns {Promise<string>}
65
71
  */
66
72
  function readBody(req) {
67
73
  return new Promise((resolve, reject) => {
68
74
  const chunks = [];
69
- req.on('data', (chunk) => chunks.push(chunk));
75
+ let totalBytes = 0;
76
+ req.on('data', (chunk) => {
77
+ totalBytes += chunk.length;
78
+ if (totalBytes > MAX_BODY_BYTES) {
79
+ req.destroy();
80
+ reject(new Error('Payload too large (max 10MB)'));
81
+ return;
82
+ }
83
+ chunks.push(chunk);
84
+ });
70
85
  req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
71
86
  req.on('error', reject);
72
87
  });
@@ -151,6 +166,11 @@ export class AwarenessLocalDaemon {
151
166
  * 7. Start fs.watch on memories dir
152
167
  */
153
168
  async start() {
169
+ // SECURITY C4: Prevent unhandled rejections from crashing the daemon
170
+ process.on('unhandledRejection', (err) => {
171
+ console.error('[awareness-local] unhandled rejection:', err?.message || err);
172
+ });
173
+
154
174
  if (await this.isRunning()) {
155
175
  console.log(
156
176
  `[awareness-local] daemon already running on port ${this.port}`
@@ -623,7 +643,7 @@ export class AwarenessLocalDaemon {
623
643
  const session = this._createSession(args.source);
624
644
  const stats = this.indexer.getStats();
625
645
  const recentCards = this.indexer.getRecentKnowledge(args.max_cards ?? 5);
626
- const openTasks = this.indexer.getOpenTasks(args.max_tasks ?? 5);
646
+ const openTasks = this.indexer.getOpenTasks(args.max_tasks ?? 0);
627
647
  const recentSessions = this.indexer.getRecentSessions(args.days ?? 7);
628
648
  const spec = this._loadSpec();
629
649
 
@@ -653,31 +673,20 @@ export class AwarenessLocalDaemon {
653
673
  const items = this.search
654
674
  ? await this.search.getFullContent(args.ids)
655
675
  : [];
676
+ // Return as readable text (no JSON noise for the Agent)
677
+ const sections = items.map(r => {
678
+ const header = r.title ? `## ${r.title}` : '';
679
+ return `${header}\n\n${r.content || '(no content)'}`;
680
+ });
656
681
  return {
657
- content: [{
658
- type: 'text',
659
- text: JSON.stringify({
660
- results: items,
661
- total: items.length,
662
- mode: 'local',
663
- detail: 'full',
664
- }),
665
- }],
682
+ content: [{ type: 'text', text: sections.join('\n\n---\n\n') || '(no results)' }],
666
683
  };
667
684
  }
668
685
 
669
686
  // Phase 1: search + summary
670
687
  if (!args.semantic_query && !args.keyword_query) {
671
688
  return {
672
- content: [{
673
- type: 'text',
674
- text: JSON.stringify({
675
- results: [],
676
- total: 0,
677
- mode: 'local',
678
- detail: 'summary',
679
- }),
680
- }],
689
+ content: [{ type: 'text', text: 'No query provided. Use semantic_query or keyword_query to search.' }],
681
690
  };
682
691
  }
683
692
 
@@ -685,17 +694,32 @@ export class AwarenessLocalDaemon {
685
694
  ? await this.search.recall(args)
686
695
  : [];
687
696
 
697
+ if (!summaries.length) {
698
+ return {
699
+ content: [{ type: 'text', text: 'No matching memories found.' }],
700
+ };
701
+ }
702
+
703
+ // Format as readable text for the Agent (not raw JSON)
704
+ const lines = summaries.map((r, i) => {
705
+ const type = r.type ? `[${r.type}]` : '';
706
+ const title = r.title || '(untitled)';
707
+ const summary = r.summary ? `\n ${r.summary}` : '';
708
+ return `${i + 1}. ${type} ${title}${summary}`;
709
+ });
710
+ const readableText = `Found ${summaries.length} memories:\n\n${lines.join('\n\n')}`;
711
+
712
+ // IDs in a separate content block for Phase 2 expansion
713
+ const idsMeta = JSON.stringify({
714
+ _ids: summaries.map(r => r.id),
715
+ _hint: 'To see full content, call awareness_recall(detail="full", ids=[...]) with IDs above.',
716
+ });
717
+
688
718
  return {
689
- content: [{
690
- type: 'text',
691
- text: JSON.stringify({
692
- results: summaries,
693
- total: summaries.length,
694
- mode: 'local',
695
- detail: args.detail || 'summary',
696
- search_method: 'hybrid',
697
- }),
698
- }],
719
+ content: [
720
+ { type: 'text', text: readableText },
721
+ { type: 'text', text: idsMeta },
722
+ ],
699
723
  };
700
724
  }
701
725
 
@@ -920,7 +944,8 @@ export class AwarenessLocalDaemon {
920
944
  */
921
945
  _apiListTasks(_req, res, url) {
922
946
  const status = url.searchParams.get('status') || null;
923
- const limit = parseInt(url.searchParams.get('limit') || '100', 10);
947
+ const limitParam = url.searchParams.get('limit');
948
+ const limit = limitParam ? parseInt(limitParam, 10) : 0;
924
949
 
925
950
  if (!this.indexer) {
926
951
  return jsonResponse(res, { items: [], total: 0 });
@@ -939,8 +964,11 @@ export class AwarenessLocalDaemon {
939
964
  sql += ' WHERE ' + conditions.join(' AND ');
940
965
  }
941
966
 
942
- sql += ` ORDER BY CASE priority WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END, created_at DESC LIMIT ?`;
943
- params.push(limit);
967
+ sql += ` ORDER BY CASE priority WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END, created_at DESC`;
968
+ if (limit > 0) {
969
+ sql += ` LIMIT ?`;
970
+ params.push(limit);
971
+ }
944
972
 
945
973
  const rows = this.indexer.db.prepare(sql).all(...params);
946
974
  return jsonResponse(res, { items: rows, total: rows.length });
@@ -1043,7 +1071,9 @@ export class AwarenessLocalDaemon {
1043
1071
  }
1044
1072
 
1045
1073
  try {
1046
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1074
+ const tmpCfg = configPath + '.tmp';
1075
+ fs.writeFileSync(tmpCfg, JSON.stringify(config, null, 2), 'utf-8');
1076
+ fs.renameSync(tmpCfg, configPath);
1047
1077
  } catch (err) {
1048
1078
  return jsonResponse(res, { error: 'Failed to save config: ' + err.message }, 500);
1049
1079
  }
@@ -1078,9 +1108,13 @@ export class AwarenessLocalDaemon {
1078
1108
  let params;
1079
1109
  try { params = JSON.parse(body); } catch { return jsonResponse(res, { error: 'Invalid JSON' }, 400); }
1080
1110
 
1081
- const apiBase = this.config?.cloud?.api_base || 'https://awareness.market/api/v1';
1082
- const interval = (params.interval || 5) * 1000;
1083
- const maxPolls = 60; // 5 minutes max
1111
+ const config = this._loadConfig();
1112
+ const apiBase = config?.cloud?.api_base || 'https://awareness.market/api/v1';
1113
+
1114
+ // SECURITY C5: Don't hold connection for 5 minutes.
1115
+ // Poll a few times (max 30s), then return pending for client to retry.
1116
+ const interval = Math.max((params.interval || 5) * 1000, 3000);
1117
+ const maxPolls = Math.min(Math.floor(30000 / interval), 6);
1084
1118
 
1085
1119
  for (let i = 0; i < maxPolls; i++) {
1086
1120
  try {
@@ -1100,8 +1134,10 @@ export class AwarenessLocalDaemon {
1100
1134
  }
1101
1135
 
1102
1136
  async _apiCloudListMemories(req, res, url) {
1103
- const apiKey = url.searchParams.get('api_key');
1104
- if (!apiKey) return jsonResponse(res, { error: 'api_key required' }, 400);
1137
+ // SECURITY C3: Use cloud config API key instead of query param (avoid log/referer leaks)
1138
+ const config = this._loadConfig();
1139
+ const apiKey = config?.cloud?.api_key;
1140
+ if (!apiKey) return jsonResponse(res, { error: 'Cloud not configured. Connect via /api/v1/cloud/connect first.' }, 400);
1105
1141
 
1106
1142
  const apiBase = this.config?.cloud?.api_base || 'https://awareness.market/api/v1';
1107
1143
  try {
@@ -1248,11 +1284,18 @@ export class AwarenessLocalDaemon {
1248
1284
  return this.indexer.createSession(source || 'local');
1249
1285
  }
1250
1286
 
1287
+ /** Max content size per memory (1 MB). */
1288
+ static MAX_CONTENT_BYTES = 1024 * 1024;
1289
+
1251
1290
  /** Write a single memory, index it, and trigger knowledge extraction. */
1252
1291
  async _remember(params) {
1253
1292
  if (!params.content) {
1254
1293
  return { error: 'content is required for remember action' };
1255
1294
  }
1295
+ // SECURITY H1: Reject oversized content to prevent FTS5/embedding freeze
1296
+ if (typeof params.content === 'string' && params.content.length > AwarenessLocalDaemon.MAX_CONTENT_BYTES) {
1297
+ return { error: `Content too large (${params.content.length} bytes, max ${AwarenessLocalDaemon.MAX_CONTENT_BYTES})` };
1298
+ }
1256
1299
 
1257
1300
  // Auto-generate title from content if not provided
1258
1301
  let title = params.title || '';
@@ -1444,10 +1487,35 @@ ${item.description || item.title || ''}
1444
1487
  }
1445
1488
  }
1446
1489
 
1490
+ // Auto-complete tasks identified by the LLM
1491
+ let tasksAutoCompleted = 0;
1492
+ if (Array.isArray(insights.completed_tasks)) {
1493
+ for (const completed of insights.completed_tasks) {
1494
+ const taskId = (completed.task_id || '').trim();
1495
+ if (!taskId) continue;
1496
+ try {
1497
+ const existing = this.indexer.db
1498
+ .prepare('SELECT * FROM tasks WHERE id = ?')
1499
+ .get(taskId);
1500
+ if (existing && existing.status !== 'done') {
1501
+ this.indexer.indexTask({
1502
+ ...existing,
1503
+ status: 'done',
1504
+ updated_at: nowISO(),
1505
+ });
1506
+ tasksAutoCompleted++;
1507
+ }
1508
+ } catch (err) {
1509
+ console.warn(`[AwarenessDaemon] Failed to auto-complete task '${taskId}':`, err.message);
1510
+ }
1511
+ }
1512
+ }
1513
+
1447
1514
  return {
1448
1515
  status: 'ok',
1449
1516
  cards_created: cardsCreated,
1450
1517
  tasks_created: tasksCreated,
1518
+ tasks_auto_completed: tasksAutoCompleted,
1451
1519
  mode: 'local',
1452
1520
  };
1453
1521
  }
@@ -1461,7 +1529,7 @@ ${item.description || item.title || ''}
1461
1529
  // Full context dump
1462
1530
  const stats = this.indexer.getStats();
1463
1531
  const knowledge = this.indexer.getRecentKnowledge(limit);
1464
- const tasks = this.indexer.getOpenTasks(limit);
1532
+ const tasks = this.indexer.getOpenTasks(0);
1465
1533
  const sessions = this.indexer.getRecentSessions(7);
1466
1534
  return { stats, knowledge_cards: knowledge, open_tasks: tasks, recent_sessions: sessions, mode: 'local' };
1467
1535
  }
@@ -1487,8 +1555,11 @@ ${item.description || item.title || ''}
1487
1555
  }
1488
1556
 
1489
1557
  if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
1490
- sql += ' ORDER BY created_at DESC LIMIT ?';
1491
- sqlParams.push(limit);
1558
+ sql += ' ORDER BY created_at DESC';
1559
+ if (limit > 0) {
1560
+ sql += ' LIMIT ?';
1561
+ sqlParams.push(limit);
1562
+ }
1492
1563
 
1493
1564
  const tasks = this.indexer.db.prepare(sql).all(...sqlParams);
1494
1565
  return { tasks, total: tasks.length, mode: 'local' };
@@ -206,11 +206,48 @@ export class LocalMcpServer {
206
206
  ids: params.ids,
207
207
  });
208
208
 
209
+ const effectiveDetail = params.detail || 'summary';
210
+
211
+ if (effectiveDetail === 'summary' && summaries.length > 0) {
212
+ // Format summary as readable text for the Agent, with IDs hidden
213
+ // but available for Phase 2 expansion
214
+ const lines = summaries.map((r, i) => {
215
+ const title = r.title || '(untitled)';
216
+ const type = r.type ? `[${r.type}]` : '';
217
+ const summary = r.summary ? `\n ${r.summary}` : '';
218
+ return `${i + 1}. ${type} ${title}${summary}`;
219
+ });
220
+ const readableText = `Found ${summaries.length} memories:\n\n${lines.join('\n\n')}`;
221
+
222
+ // Return readable text + structured data for programmatic use
223
+ return {
224
+ content: [
225
+ { type: 'text', text: readableText },
226
+ { type: 'text', text: JSON.stringify({
227
+ _ids: summaries.map(r => r.id),
228
+ _meta: { detail: 'summary', total: summaries.length, mode: 'local' },
229
+ }) },
230
+ ],
231
+ };
232
+ }
233
+
234
+ if (effectiveDetail === 'full' && summaries.length > 0) {
235
+ // Full content — return as readable text
236
+ const sections = summaries.map(r => {
237
+ const header = r.title ? `## ${r.title}` : '';
238
+ return `${header}\n\n${r.content || '(no content)'}`;
239
+ });
240
+ return {
241
+ content: [{ type: 'text', text: sections.join('\n\n---\n\n') }],
242
+ };
243
+ }
244
+
245
+ // Fallback / empty results
209
246
  return mcpResult({
210
247
  results: summaries,
211
248
  total: summaries.length,
212
249
  mode: 'local',
213
- detail: params.detail || 'summary',
250
+ detail: effectiveDetail,
214
251
  search_method: 'hybrid',
215
252
  });
216
253
  } catch (err) {