@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.
- package/bin/awareness-local.mjs +489 -0
- package/package.json +31 -0
- package/src/api.mjs +122 -0
- package/src/core/cloud-sync.mjs +970 -0
- package/src/core/config.mjs +303 -0
- package/src/core/embedder.mjs +239 -0
- package/src/core/index.mjs +34 -0
- package/src/core/indexer.mjs +726 -0
- package/src/core/knowledge-extractor.mjs +629 -0
- package/src/core/memory-store.mjs +665 -0
- package/src/core/search.mjs +633 -0
- package/src/daemon.mjs +1720 -0
- package/src/mcp-server.mjs +335 -0
- package/src/spec/awareness-spec.json +393 -0
- package/src/web/index.html +1015 -0
|
@@ -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
|
+
}
|