@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,665 @@
1
+ /**
2
+ * MemoryStore — Markdown file management for Awareness Local
3
+ *
4
+ * Responsibilities:
5
+ * - Write memories as Markdown with YAML front matter (atomic writes)
6
+ * - Read / parse Markdown files back to structured objects
7
+ * - List, filter, and update memory files
8
+ * - Detect unindexed files for incremental indexing
9
+ *
10
+ * Pure Node.js — no external dependencies.
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import crypto from 'node:crypto';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // MemoryStore
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export class MemoryStore {
23
+ /**
24
+ * @param {string} rootDir - Absolute path to the project root
25
+ */
26
+ constructor(rootDir) {
27
+ this.rootDir = rootDir;
28
+ this.memoriesDir = path.join(rootDir, '.awareness', 'memories');
29
+ this.knowledgeDir = path.join(rootDir, '.awareness', 'knowledge');
30
+ this.tasksDir = path.join(rootDir, '.awareness', 'tasks');
31
+ }
32
+
33
+ // -----------------------------------------------------------------------
34
+ // ID generation
35
+ // -----------------------------------------------------------------------
36
+
37
+ /**
38
+ * Generate a unique memory ID.
39
+ * Format: "mem_{YYYYMMDDHHmmss}_{4-char-hex}"
40
+ *
41
+ * @returns {string} e.g. "mem_20260321_143022_a3f2"
42
+ */
43
+ generateId() {
44
+ const now = new Date();
45
+ const ts = formatTimestamp(now);
46
+ const rand = crypto.randomBytes(2).toString('hex'); // 4 hex chars
47
+ return `mem_${ts}_${rand}`;
48
+ }
49
+
50
+ // -----------------------------------------------------------------------
51
+ // Filename
52
+ // -----------------------------------------------------------------------
53
+
54
+ /**
55
+ * Build a human-readable filename from a memory object.
56
+ * Format: "{YYYY-MM-DD}_{slugified-title-max-50}.md"
57
+ *
58
+ * Falls back to the generated id when title is missing.
59
+ *
60
+ * @param {object} memory - must have at least { title? | content }
61
+ * @returns {string}
62
+ */
63
+ buildFilename(memory) {
64
+ const now = new Date();
65
+ const dateStr = formatDate(now);
66
+
67
+ // Derive a title from explicit field, first heading, or first line
68
+ const rawTitle =
69
+ memory.title ||
70
+ extractFirstHeading(memory.content) ||
71
+ (memory.content || '').split('\n')[0] ||
72
+ 'untitled';
73
+
74
+ const slug = slugifyTitle(rawTitle, 50);
75
+ return `${dateStr}_${slug}.md`;
76
+ }
77
+
78
+ // -----------------------------------------------------------------------
79
+ // Markdown serialization
80
+ // -----------------------------------------------------------------------
81
+
82
+ /**
83
+ * Serialize a memory into Markdown with YAML front matter.
84
+ *
85
+ * @param {string} id
86
+ * @param {object} memory
87
+ * @returns {string} Complete Markdown string
88
+ */
89
+ toMarkdown(id, memory) {
90
+ const now = new Date().toISOString();
91
+
92
+ const frontMatter = {
93
+ id,
94
+ type: memory.type || 'turn_summary',
95
+ session_id: memory.session_id || null,
96
+ agent_role: memory.agent_role || 'builder_agent',
97
+ tags: Array.isArray(memory.tags) ? memory.tags : [],
98
+ created_at: memory.created_at || now,
99
+ updated_at: memory.updated_at || now,
100
+ source: memory.source || 'manual',
101
+ status: memory.status || 'active',
102
+ related: Array.isArray(memory.related) ? memory.related : [],
103
+ };
104
+
105
+ const yamlLines = serializeYaml(frontMatter);
106
+ const body = (memory.content || '').trim();
107
+
108
+ return `---\n${yamlLines}---\n\n${body}\n`;
109
+ }
110
+
111
+ /**
112
+ * Parse a Markdown string that starts with YAML front matter.
113
+ * Returns { metadata: {...}, content: "..." }.
114
+ *
115
+ * @param {string} raw - Full file content
116
+ * @returns {{ metadata: object, content: string }}
117
+ */
118
+ parseMarkdown(raw) {
119
+ if (!raw || typeof raw !== 'string') {
120
+ return { metadata: {}, content: '' };
121
+ }
122
+
123
+ const trimmed = raw.replace(/^\uFEFF/, ''); // strip BOM
124
+ const match = trimmed.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
125
+
126
+ if (!match) {
127
+ // No front matter — treat entire content as body
128
+ return { metadata: {}, content: trimmed.trim() };
129
+ }
130
+
131
+ const yamlBlock = match[1];
132
+ const body = (match[2] || '').trim();
133
+ const metadata = parseYaml(yamlBlock);
134
+
135
+ return { metadata, content: body };
136
+ }
137
+
138
+ // -----------------------------------------------------------------------
139
+ // File I/O
140
+ // -----------------------------------------------------------------------
141
+
142
+ /**
143
+ * Atomic write: write to a temporary file then rename.
144
+ * Prevents partial writes from corrupting the target file.
145
+ *
146
+ * @param {string} filepath - Absolute target path
147
+ * @param {string} content
148
+ */
149
+ async atomicWrite(filepath, content) {
150
+ const dir = path.dirname(filepath);
151
+ fs.mkdirSync(dir, { recursive: true });
152
+
153
+ const tmpPath = `${filepath}.tmp`;
154
+ try {
155
+ fs.writeFileSync(tmpPath, content, 'utf-8');
156
+ fs.renameSync(tmpPath, filepath);
157
+ } catch (err) {
158
+ // Clean up temp file on failure
159
+ try {
160
+ fs.unlinkSync(tmpPath);
161
+ } catch {
162
+ // ignore cleanup errors
163
+ }
164
+ throw err;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Write a memory to disk.
170
+ *
171
+ * @param {object} memory - { type, content, title?, tags?, agent_role?, session_id?, source?, related? }
172
+ * @returns {{ id: string, filepath: string }}
173
+ */
174
+ async write(memory) {
175
+ const id = this.generateId();
176
+ const filename = this.buildFilename(memory);
177
+ const filepath = path.join(this.memoriesDir, filename);
178
+
179
+ // Handle filename collision (unlikely but possible)
180
+ const finalPath = await this._resolveCollision(filepath);
181
+ const markdown = this.toMarkdown(id, memory);
182
+ await this.atomicWrite(finalPath, markdown);
183
+
184
+ return { id, filepath: finalPath };
185
+ }
186
+
187
+ /**
188
+ * Read and parse a memory file by its id.
189
+ * Searches the memories directory for a file whose front matter id matches.
190
+ *
191
+ * @param {string} id
192
+ * @returns {{ metadata: object, content: string, filepath: string } | null}
193
+ */
194
+ async read(id) {
195
+ // Fast path: scan directory for files and check front matter
196
+ const files = this._listMdFiles(this.memoriesDir);
197
+
198
+ for (const filepath of files) {
199
+ try {
200
+ const raw = fs.readFileSync(filepath, 'utf-8');
201
+ // Quick check before full parse — look for the id in front matter
202
+ if (!raw.includes(id)) continue;
203
+
204
+ const parsed = this.parseMarkdown(raw);
205
+ if (parsed.metadata.id === id) {
206
+ return { ...parsed, filepath };
207
+ }
208
+ } catch {
209
+ // Skip unreadable files
210
+ continue;
211
+ }
212
+ }
213
+
214
+ return null;
215
+ }
216
+
217
+ /**
218
+ * Read raw file content by filepath.
219
+ *
220
+ * @param {string} filepath - Absolute path
221
+ * @returns {string}
222
+ */
223
+ readContent(filepath) {
224
+ return fs.readFileSync(filepath, 'utf-8');
225
+ }
226
+
227
+ /**
228
+ * List memory files with optional filtering.
229
+ *
230
+ * @param {object} filter
231
+ * @param {string} [filter.type] - Memory type to match
232
+ * @param {string[]} [filter.tags] - At least one tag must match
233
+ * @param {string} [filter.status] - Status to match
234
+ * @returns {Array<{ metadata: object, content: string, filepath: string }>}
235
+ */
236
+ async list(filter = {}) {
237
+ const files = this._listMdFiles(this.memoriesDir);
238
+ const results = [];
239
+
240
+ for (const filepath of files) {
241
+ try {
242
+ const raw = fs.readFileSync(filepath, 'utf-8');
243
+ const parsed = this.parseMarkdown(raw);
244
+ const meta = parsed.metadata;
245
+
246
+ // Apply filters
247
+ if (filter.type && meta.type !== filter.type) continue;
248
+ if (filter.status && meta.status !== filter.status) continue;
249
+ if (filter.tags && Array.isArray(filter.tags) && filter.tags.length > 0) {
250
+ const memTags = Array.isArray(meta.tags) ? meta.tags : [];
251
+ const hasOverlap = filter.tags.some((t) => memTags.includes(t));
252
+ if (!hasOverlap) continue;
253
+ }
254
+
255
+ results.push({ ...parsed, filepath });
256
+ } catch {
257
+ // Skip unreadable files
258
+ continue;
259
+ }
260
+ }
261
+
262
+ // Sort by created_at descending (newest first)
263
+ results.sort((a, b) => {
264
+ const aDate = a.metadata.created_at || '';
265
+ const bDate = b.metadata.created_at || '';
266
+ return bDate.localeCompare(aDate);
267
+ });
268
+
269
+ return results;
270
+ }
271
+
272
+ /**
273
+ * Update the status field in a memory's front matter.
274
+ * Rewrites the file atomically.
275
+ *
276
+ * @param {string} id
277
+ * @param {string} newStatus - e.g. "active", "superseded", "archived"
278
+ * @returns {boolean} true if updated, false if not found
279
+ */
280
+ async updateStatus(id, newStatus) {
281
+ const entry = await this.read(id);
282
+ if (!entry) return false;
283
+
284
+ const now = new Date().toISOString();
285
+ entry.metadata.status = newStatus;
286
+ entry.metadata.updated_at = now;
287
+
288
+ const yamlLines = serializeYaml(entry.metadata);
289
+ const newContent = `---\n${yamlLines}---\n\n${entry.content}\n`;
290
+ await this.atomicWrite(entry.filepath, newContent);
291
+
292
+ return true;
293
+ }
294
+
295
+ /**
296
+ * Compare files on disk against an indexer to find unindexed files.
297
+ *
298
+ * @param {{ isIndexed: (filepath: string) => boolean }} indexer
299
+ * An object with an `isIndexed(filepath)` method.
300
+ * @returns {Array<{ filepath: string, raw: string }>}
301
+ */
302
+ async getUnindexedFiles(indexer) {
303
+ const files = this._listMdFiles(this.memoriesDir);
304
+ const unindexed = [];
305
+
306
+ for (const filepath of files) {
307
+ try {
308
+ const indexed = typeof indexer.isIndexed === 'function'
309
+ ? indexer.isIndexed(filepath)
310
+ : false;
311
+
312
+ if (!indexed) {
313
+ const raw = fs.readFileSync(filepath, 'utf-8');
314
+ unindexed.push({ filepath, raw });
315
+ }
316
+ } catch {
317
+ continue;
318
+ }
319
+ }
320
+
321
+ return unindexed;
322
+ }
323
+
324
+ // -----------------------------------------------------------------------
325
+ // Private helpers
326
+ // -----------------------------------------------------------------------
327
+
328
+ /**
329
+ * Recursively list all .md files under a directory.
330
+ * @param {string} dir
331
+ * @returns {string[]} Sorted array of absolute paths
332
+ */
333
+ _listMdFiles(dir) {
334
+ if (!fs.existsSync(dir)) return [];
335
+
336
+ const results = [];
337
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
338
+
339
+ for (const entry of entries) {
340
+ const fullPath = path.join(dir, entry.name);
341
+ if (entry.isDirectory()) {
342
+ results.push(...this._listMdFiles(fullPath));
343
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
344
+ results.push(fullPath);
345
+ }
346
+ }
347
+
348
+ return results.sort();
349
+ }
350
+
351
+ /**
352
+ * If filepath already exists, append a numeric suffix before .md
353
+ * @param {string} filepath
354
+ * @returns {string}
355
+ */
356
+ async _resolveCollision(filepath) {
357
+ if (!fs.existsSync(filepath)) return filepath;
358
+
359
+ const ext = path.extname(filepath);
360
+ const base = filepath.slice(0, -ext.length);
361
+ let counter = 1;
362
+
363
+ while (fs.existsSync(`${base}-${counter}${ext}`)) {
364
+ counter++;
365
+ if (counter > 999) {
366
+ // Safety valve — use random suffix
367
+ const rand = crypto.randomBytes(3).toString('hex');
368
+ return `${base}-${rand}${ext}`;
369
+ }
370
+ }
371
+
372
+ return `${base}-${counter}${ext}`;
373
+ }
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Pure-function helpers (no dependencies)
378
+ // ---------------------------------------------------------------------------
379
+
380
+ /**
381
+ * Format a Date as "YYYYMMDD_HHmmss" for IDs.
382
+ * @param {Date} date
383
+ * @returns {string}
384
+ */
385
+ function formatTimestamp(date) {
386
+ const y = date.getFullYear();
387
+ const mo = String(date.getMonth() + 1).padStart(2, '0');
388
+ const d = String(date.getDate()).padStart(2, '0');
389
+ const h = String(date.getHours()).padStart(2, '0');
390
+ const mi = String(date.getMinutes()).padStart(2, '0');
391
+ const s = String(date.getSeconds()).padStart(2, '0');
392
+ return `${y}${mo}${d}_${h}${mi}${s}`;
393
+ }
394
+
395
+ /**
396
+ * Format a Date as "YYYY-MM-DD" for filenames.
397
+ * @param {Date} date
398
+ * @returns {string}
399
+ */
400
+ function formatDate(date) {
401
+ const y = date.getFullYear();
402
+ const mo = String(date.getMonth() + 1).padStart(2, '0');
403
+ const d = String(date.getDate()).padStart(2, '0');
404
+ return `${y}-${mo}-${d}`;
405
+ }
406
+
407
+ /**
408
+ * Turn an arbitrary string into a filesystem-safe slug.
409
+ * Handles CJK characters by transliterating to pinyin-like dashes.
410
+ * Non-alphanumeric chars become hyphens; consecutive hyphens are collapsed.
411
+ *
412
+ * @param {string} text
413
+ * @param {number} maxLen
414
+ * @returns {string}
415
+ */
416
+ function slugifyTitle(text, maxLen = 50) {
417
+ let slug = text
418
+ .toLowerCase()
419
+ // Replace CJK and other non-Latin chars with hyphens
420
+ // Keep basic Latin letters, digits, hyphens, and spaces
421
+ .replace(/[^\p{L}\p{N}\s-]/gu, '')
422
+ // Replace whitespace and underscores with hyphens
423
+ .replace(/[\s_]+/g, '-')
424
+ // Replace any non-ASCII-letter/digit/hyphen with hyphen
425
+ .replace(/[^a-z0-9\u00C0-\u024F\u0400-\u04FF\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF-]/g, '-')
426
+ // Collapse multiple hyphens
427
+ .replace(/-+/g, '-')
428
+ // Trim leading/trailing hyphens
429
+ .replace(/^-|-$/g, '');
430
+
431
+ // Truncate to maxLen, but don't cut in the middle of a multi-byte char
432
+ if (slug.length > maxLen) {
433
+ slug = slug.slice(0, maxLen);
434
+ // Remove trailing hyphen from truncation
435
+ slug = slug.replace(/-$/, '');
436
+ }
437
+
438
+ return slug || 'untitled';
439
+ }
440
+
441
+ /**
442
+ * Extract the first Markdown heading from content.
443
+ * @param {string} content
444
+ * @returns {string|null}
445
+ */
446
+ function extractFirstHeading(content) {
447
+ if (!content) return null;
448
+ const match = content.match(/^#+\s+(.+)$/m);
449
+ return match ? match[1].trim() : null;
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Minimal YAML serializer (front matter only, no dependency)
454
+ // ---------------------------------------------------------------------------
455
+
456
+ /**
457
+ * Serialize a flat/simple object to YAML-like lines for front matter.
458
+ * Supports: string, number, boolean, null, arrays of scalars, Date.
459
+ *
460
+ * @param {object} obj
461
+ * @returns {string} Multi-line YAML (without --- delimiters)
462
+ */
463
+ function serializeYaml(obj) {
464
+ const lines = [];
465
+
466
+ for (const [key, value] of Object.entries(obj)) {
467
+ if (value === null || value === undefined) {
468
+ lines.push(`${key}: null`);
469
+ } else if (Array.isArray(value)) {
470
+ if (value.length === 0) {
471
+ lines.push(`${key}: []`);
472
+ } else {
473
+ const items = value.map((v) => yamlScalar(v)).join(', ');
474
+ lines.push(`${key}: [${items}]`);
475
+ }
476
+ } else if (typeof value === 'boolean') {
477
+ lines.push(`${key}: ${value}`);
478
+ } else if (typeof value === 'number') {
479
+ lines.push(`${key}: ${value}`);
480
+ } else {
481
+ lines.push(`${key}: ${yamlScalar(value)}`);
482
+ }
483
+ }
484
+
485
+ return lines.join('\n') + '\n';
486
+ }
487
+
488
+ /**
489
+ * Format a scalar value for inline YAML.
490
+ * Strings that contain special chars are quoted.
491
+ *
492
+ * @param {*} val
493
+ * @returns {string}
494
+ */
495
+ function yamlScalar(val) {
496
+ if (val === null || val === undefined) return 'null';
497
+ if (typeof val === 'boolean') return String(val);
498
+ if (typeof val === 'number') return String(val);
499
+
500
+ const str = String(val);
501
+ // Quote if the string contains YAML-special characters
502
+ if (
503
+ str === '' ||
504
+ str.includes(':') ||
505
+ str.includes('#') ||
506
+ str.includes('{') ||
507
+ str.includes('}') ||
508
+ str.includes('[') ||
509
+ str.includes(']') ||
510
+ str.includes(',') ||
511
+ str.includes('&') ||
512
+ str.includes('*') ||
513
+ str.includes('?') ||
514
+ str.includes('|') ||
515
+ str.includes('-') ||
516
+ str.includes('<') ||
517
+ str.includes('>') ||
518
+ str.includes('=') ||
519
+ str.includes('!') ||
520
+ str.includes('%') ||
521
+ str.includes('@') ||
522
+ str.includes('`') ||
523
+ str.includes('"') ||
524
+ str.includes("'") ||
525
+ str.includes('\n') ||
526
+ str.startsWith(' ') ||
527
+ str.endsWith(' ') ||
528
+ str === 'true' ||
529
+ str === 'false' ||
530
+ str === 'null' ||
531
+ str === 'yes' ||
532
+ str === 'no'
533
+ ) {
534
+ // Use double quotes, escape inner double quotes and backslashes
535
+ const escaped = str
536
+ .replace(/\\/g, '\\\\')
537
+ .replace(/"/g, '\\"')
538
+ .replace(/\n/g, '\\n');
539
+ return `"${escaped}"`;
540
+ }
541
+
542
+ return str;
543
+ }
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // Minimal YAML parser (front matter only, no dependency)
547
+ // ---------------------------------------------------------------------------
548
+
549
+ /**
550
+ * Parse a simple YAML block into a plain object.
551
+ * Handles: scalars, inline arrays [a, b], quoted strings, booleans, numbers, null.
552
+ * Does NOT handle nested objects, multi-line strings, anchors, or aliases.
553
+ *
554
+ * @param {string} yaml
555
+ * @returns {object}
556
+ */
557
+ function parseYaml(yaml) {
558
+ const result = {};
559
+ const lines = yaml.split(/\r?\n/);
560
+
561
+ for (const line of lines) {
562
+ const trimmed = line.trim();
563
+ if (!trimmed || trimmed.startsWith('#')) continue;
564
+
565
+ // Split on first colon
566
+ const colonIdx = trimmed.indexOf(':');
567
+ if (colonIdx === -1) continue;
568
+
569
+ const key = trimmed.slice(0, colonIdx).trim();
570
+ let rawValue = trimmed.slice(colonIdx + 1).trim();
571
+
572
+ // Strip inline comments (but not inside quoted strings)
573
+ if (!rawValue.startsWith('"') && !rawValue.startsWith("'")) {
574
+ const commentIdx = rawValue.indexOf(' #');
575
+ if (commentIdx !== -1) {
576
+ rawValue = rawValue.slice(0, commentIdx).trim();
577
+ }
578
+ }
579
+
580
+ result[key] = parseYamlValue(rawValue);
581
+ }
582
+
583
+ return result;
584
+ }
585
+
586
+ /**
587
+ * Parse a single YAML value string.
588
+ *
589
+ * @param {string} raw
590
+ * @returns {*}
591
+ */
592
+ function parseYamlValue(raw) {
593
+ if (raw === '' || raw === 'null' || raw === '~') return null;
594
+ if (raw === 'true' || raw === 'yes') return true;
595
+ if (raw === 'false' || raw === 'no') return false;
596
+
597
+ // Inline array: [a, b, c]
598
+ if (raw.startsWith('[') && raw.endsWith(']')) {
599
+ const inner = raw.slice(1, -1).trim();
600
+ if (inner === '') return [];
601
+ return splitYamlArray(inner).map((item) => parseYamlValue(item.trim()));
602
+ }
603
+
604
+ // Quoted string (double)
605
+ if (raw.startsWith('"') && raw.endsWith('"')) {
606
+ return raw
607
+ .slice(1, -1)
608
+ .replace(/\\n/g, '\n')
609
+ .replace(/\\"/g, '"')
610
+ .replace(/\\\\/g, '\\');
611
+ }
612
+
613
+ // Quoted string (single)
614
+ if (raw.startsWith("'") && raw.endsWith("'")) {
615
+ return raw.slice(1, -1).replace(/''/g, "'");
616
+ }
617
+
618
+ // Number
619
+ if (/^-?\d+(\.\d+)?$/.test(raw)) {
620
+ return Number(raw);
621
+ }
622
+
623
+ // Plain string
624
+ return raw;
625
+ }
626
+
627
+ /**
628
+ * Split a YAML inline array body respecting quoted strings.
629
+ * e.g. 'jwt, "a, b", auth' → ['jwt', '"a, b"', 'auth']
630
+ *
631
+ * @param {string} inner
632
+ * @returns {string[]}
633
+ */
634
+ function splitYamlArray(inner) {
635
+ const items = [];
636
+ let current = '';
637
+ let inQuote = false;
638
+ let quoteChar = '';
639
+
640
+ for (let i = 0; i < inner.length; i++) {
641
+ const ch = inner[i];
642
+
643
+ if (inQuote) {
644
+ current += ch;
645
+ if (ch === quoteChar && inner[i - 1] !== '\\') {
646
+ inQuote = false;
647
+ }
648
+ } else if (ch === '"' || ch === "'") {
649
+ inQuote = true;
650
+ quoteChar = ch;
651
+ current += ch;
652
+ } else if (ch === ',') {
653
+ items.push(current.trim());
654
+ current = '';
655
+ } else {
656
+ current += ch;
657
+ }
658
+ }
659
+
660
+ if (current.trim()) {
661
+ items.push(current.trim());
662
+ }
663
+
664
+ return items;
665
+ }