@c4t4/heyamigo 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.
Files changed (49) hide show
  1. package/.gitignore +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +261 -0
  4. package/config/access.example.json +88 -0
  5. package/config/config.example.json +72 -0
  6. package/config/import-instructions.HOWTO.md +58 -0
  7. package/config/import-instructions.md +67 -0
  8. package/config/memory-instructions.md +40 -0
  9. package/config/personalities/casual.md +24 -0
  10. package/config/personalities/professional.md +25 -0
  11. package/config/personalities/sharp.md +45 -0
  12. package/dist/ai/claude.js +153 -0
  13. package/dist/ai/sessions.js +63 -0
  14. package/dist/cli/import.js +17 -0
  15. package/dist/cli/index.js +70 -0
  16. package/dist/cli/service.js +105 -0
  17. package/dist/cli/setup.js +701 -0
  18. package/dist/cli/start.js +37 -0
  19. package/dist/cli/supervisor.js +37 -0
  20. package/dist/config.js +104 -0
  21. package/dist/gateway/bootstrap.js +56 -0
  22. package/dist/gateway/commands.js +58 -0
  23. package/dist/gateway/incoming.js +239 -0
  24. package/dist/gateway/outgoing.js +168 -0
  25. package/dist/gateway/triggers.js +75 -0
  26. package/dist/index.js +30 -0
  27. package/dist/logger.js +7 -0
  28. package/dist/memory/digest-flag.js +8 -0
  29. package/dist/memory/digest.js +211 -0
  30. package/dist/memory/frontmatter.js +100 -0
  31. package/dist/memory/importer.js +103 -0
  32. package/dist/memory/paths.js +26 -0
  33. package/dist/memory/preamble.js +98 -0
  34. package/dist/memory/router.js +90 -0
  35. package/dist/memory/scheduler.js +85 -0
  36. package/dist/memory/store.js +183 -0
  37. package/dist/promptlog.js +52 -0
  38. package/dist/queue/persistence.js +68 -0
  39. package/dist/queue/queue.js +49 -0
  40. package/dist/queue/types.js +1 -0
  41. package/dist/queue/worker.js +51 -0
  42. package/dist/store/media.js +108 -0
  43. package/dist/store/messages.js +33 -0
  44. package/dist/wa/auth.js +9 -0
  45. package/dist/wa/sender.js +79 -0
  46. package/dist/wa/socket.js +84 -0
  47. package/dist/wa/whitelist.js +213 -0
  48. package/package.json +63 -0
  49. package/scripts/start-browser.sh +158 -0
@@ -0,0 +1,168 @@
1
+ import { existsSync, unlinkSync } from 'fs';
2
+ import { isJidGroup } from 'baileys';
3
+ import { config } from '../config.js';
4
+ import { logger } from '../logger.js';
5
+ import { append } from '../store/messages.js';
6
+ import { detectMediaType, sendFile, sendText } from '../wa/sender.js';
7
+ import { getSocket } from '../wa/socket.js';
8
+ // Matches [FILE: path], [IMAGE: path], [VIDEO: path], [AUDIO: path], [DOCUMENT: path]
9
+ const FILE_TAG_RE = /\[(?:FILE|IMAGE|VIDEO|AUDIO|DOCUMENT):\s*([^\]]+)\]/gi;
10
+ function extractFiles(reply) {
11
+ const files = [];
12
+ const text = reply.replace(FILE_TAG_RE, (_, path) => {
13
+ const trimmed = path.trim();
14
+ if (existsSync(trimmed)) {
15
+ files.push(trimmed);
16
+ }
17
+ else {
18
+ logger.warn({ path: trimmed }, 'file path not found, skipping');
19
+ }
20
+ return '';
21
+ }).trim();
22
+ return { text, files };
23
+ }
24
+ export async function handleReply(job, result, originalMsg) {
25
+ const sock = getSocket();
26
+ if (!sock) {
27
+ logger.warn({ jid: job.jid }, 'no socket available to send reply');
28
+ return;
29
+ }
30
+ const raw = result.reply?.replaceAll('—', ', ').replaceAll('–', '-');
31
+ if (!raw)
32
+ return;
33
+ const { text, files } = extractFiles(raw);
34
+ const isGroup = isJidGroup(job.jid) === true;
35
+ const quoted = isGroup && config.reply.quoteInGroups ? originalMsg : undefined;
36
+ try {
37
+ // Send files first (images, videos, PDFs, audio, etc.)
38
+ for (const filePath of files) {
39
+ const isFirst = filePath === files[0];
40
+ const mediaType = detectMediaType(filePath);
41
+ // First file gets caption if text is short + single file + supports captions
42
+ const supportsCaption = mediaType !== 'audio';
43
+ const caption = isFirst && text && text.length <= 1000 && files.length === 1 && supportsCaption
44
+ ? text
45
+ : undefined;
46
+ await sendFile(sock, job.jid, filePath, caption, isFirst ? quoted : undefined);
47
+ await append({
48
+ id: `reply-file-${Date.now()}`,
49
+ jid: job.jid,
50
+ direction: 'out',
51
+ fromMe: true,
52
+ sender: sock.user?.id ?? '',
53
+ senderNumber: config.owner.number,
54
+ timestamp: Math.floor(Date.now() / 1000),
55
+ text: caption || `[${mediaType}: ${filePath}]`,
56
+ messageType: `${mediaType}Message`,
57
+ mediaPath: filePath,
58
+ mediaType,
59
+ });
60
+ logger.info({ jid: job.jid, path: filePath, mediaType }, 'file sent');
61
+ // Clean up temp file after sending
62
+ try {
63
+ unlinkSync(filePath);
64
+ }
65
+ catch { }
66
+ if (files.length > 1)
67
+ await sleep(config.reply.chunkDelayMs);
68
+ }
69
+ // Send text (skip if already used as caption on single file)
70
+ const textAlreadySent = files.length === 1 && text && text.length <= 1000 && detectMediaType(files[0]) !== 'audio';
71
+ if (text && !textAlreadySent) {
72
+ const chunks = chunkText(text, config.reply.chunkChars);
73
+ for (let i = 0; i < chunks.length; i++) {
74
+ const chunk = chunks[i];
75
+ const q = i === 0 && files.length === 0 ? quoted : undefined;
76
+ await sendText(sock, job.jid, chunk, q);
77
+ await append({
78
+ id: `reply-${Date.now()}-${i}`,
79
+ jid: job.jid,
80
+ direction: 'out',
81
+ fromMe: true,
82
+ sender: sock.user?.id ?? '',
83
+ senderNumber: config.owner.number,
84
+ timestamp: Math.floor(Date.now() / 1000),
85
+ text: chunk,
86
+ messageType: 'conversation',
87
+ });
88
+ if (i < chunks.length - 1)
89
+ await sleep(config.reply.chunkDelayMs);
90
+ }
91
+ }
92
+ if (config.reply.typingIndicator) {
93
+ await sock
94
+ .sendPresenceUpdate('paused', job.jid)
95
+ .catch(() => undefined);
96
+ }
97
+ logger.info({
98
+ jid: job.jid,
99
+ files: files.length,
100
+ chars: text.length,
101
+ }, 'reply sent');
102
+ }
103
+ catch (err) {
104
+ logger.error({ err, jid: job.jid }, 'failed to send reply');
105
+ }
106
+ }
107
+ function sleep(ms) {
108
+ return new Promise((r) => setTimeout(r, ms));
109
+ }
110
+ export function chunkText(text, maxChars) {
111
+ if (text.length <= maxChars)
112
+ return [text];
113
+ const chunks = [];
114
+ const paragraphs = text.split(/\n\s*\n/);
115
+ let current = '';
116
+ const flush = () => {
117
+ if (current) {
118
+ chunks.push(current);
119
+ current = '';
120
+ }
121
+ };
122
+ for (const para of paragraphs) {
123
+ const joiner = current ? '\n\n' : '';
124
+ if (current.length + joiner.length + para.length <= maxChars) {
125
+ current += joiner + para;
126
+ continue;
127
+ }
128
+ flush();
129
+ if (para.length <= maxChars) {
130
+ current = para;
131
+ }
132
+ else {
133
+ const parts = splitLong(para, maxChars);
134
+ for (let i = 0; i < parts.length - 1; i++)
135
+ chunks.push(parts[i]);
136
+ current = parts[parts.length - 1] ?? '';
137
+ }
138
+ }
139
+ flush();
140
+ return chunks;
141
+ }
142
+ function splitLong(text, maxChars) {
143
+ const out = [];
144
+ const sentences = text.split(/(?<=[.!?])\s+/);
145
+ let current = '';
146
+ for (const s of sentences) {
147
+ const joiner = current ? ' ' : '';
148
+ if (current.length + joiner.length + s.length <= maxChars) {
149
+ current += joiner + s;
150
+ continue;
151
+ }
152
+ if (current) {
153
+ out.push(current);
154
+ current = '';
155
+ }
156
+ if (s.length <= maxChars) {
157
+ current = s;
158
+ }
159
+ else {
160
+ for (let i = 0; i < s.length; i += maxChars) {
161
+ out.push(s.slice(i, i + maxChars));
162
+ }
163
+ }
164
+ }
165
+ if (current)
166
+ out.push(current);
167
+ return out;
168
+ }
@@ -0,0 +1,75 @@
1
+ import { jidDecode } from 'baileys';
2
+ import { config } from '../config.js';
3
+ function escapeRegex(s) {
4
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
5
+ }
6
+ function aliasMatches(text, aliases) {
7
+ for (const alias of aliases) {
8
+ const re = new RegExp(`(^|[^a-zA-Z0-9_])${escapeRegex(alias)}([^a-zA-Z0-9_]|$)`, 'i');
9
+ if (re.test(text))
10
+ return alias;
11
+ }
12
+ return null;
13
+ }
14
+ function ownerNumbers(sock) {
15
+ const out = new Set();
16
+ if (config.owner.number)
17
+ out.add(config.owner.number);
18
+ const pn = sock.user?.id ? jidDecode(sock.user.id)?.user : undefined;
19
+ if (pn)
20
+ out.add(pn);
21
+ const lid = sock.user?.lid ? jidDecode(sock.user.lid)?.user : undefined;
22
+ if (lid)
23
+ out.add(lid);
24
+ return out;
25
+ }
26
+ export function checkTrigger(params) {
27
+ const { isGroup, text, msg, sock } = params;
28
+ const mode = isGroup
29
+ ? config.triggers.groupMode
30
+ : config.triggers.dmMode;
31
+ if (mode === 'off')
32
+ return { triggered: false, reason: 'mode=off' };
33
+ if (mode === 'all')
34
+ return { triggered: true, reason: 'mode=all' };
35
+ const prefix = config.commands.prefix;
36
+ if (mode === 'command') {
37
+ return text.trim().startsWith(prefix)
38
+ ? { triggered: true, reason: 'command prefix' }
39
+ : { triggered: false, reason: 'no command prefix' };
40
+ }
41
+ // mode === 'mention'
42
+ // 1. Alias in text (word boundary, case insensitive)
43
+ const alias = aliasMatches(text, config.triggers.aliases);
44
+ if (alias)
45
+ return { triggered: true, reason: `alias:${alias}` };
46
+ // Extract contextInfo from any message type (text, image, video, etc.)
47
+ const content = msg.message ?? {};
48
+ const contextInfo = (content.extendedTextMessage?.contextInfo) ??
49
+ (content.imageMessage?.contextInfo) ??
50
+ (content.videoMessage?.contextInfo) ??
51
+ (content.audioMessage?.contextInfo) ??
52
+ (content.documentMessage?.contextInfo) ??
53
+ (content.documentWithCaptionMessage?.message?.documentMessage?.contextInfo) ??
54
+ (content.stickerMessage?.contextInfo);
55
+ // 2. WA @mention pointing at owner
56
+ const owners = ownerNumbers(sock);
57
+ const mentioned = contextInfo?.mentionedJid ?? [];
58
+ for (const m of mentioned) {
59
+ const user = jidDecode(m)?.user;
60
+ if (user && owners.has(user)) {
61
+ return { triggered: true, reason: 'wa mention' };
62
+ }
63
+ }
64
+ // 3. Reply to a bot/owner message
65
+ if (config.triggers.replyToBotCounts) {
66
+ const quotedParticipant = contextInfo?.participant;
67
+ if (quotedParticipant) {
68
+ const user = jidDecode(quotedParticipant)?.user;
69
+ if (user && owners.has(user)) {
70
+ return { triggered: true, reason: 'reply to bot' };
71
+ }
72
+ }
73
+ }
74
+ return { triggered: false, reason: 'no trigger match' };
75
+ }
package/dist/index.js ADDED
@@ -0,0 +1,30 @@
1
+ import { attachIncoming } from './gateway/incoming.js';
2
+ import { handleReply } from './gateway/outgoing.js';
3
+ import { logger } from './logger.js';
4
+ import { startScheduler } from './memory/scheduler.js';
5
+ import { replayPending } from './queue/queue.js';
6
+ import { startSocket } from './wa/socket.js';
7
+ async function main() {
8
+ logger.info('heyamigo starting');
9
+ startScheduler();
10
+ await startSocket((sock) => {
11
+ attachIncoming(sock);
12
+ });
13
+ // Replay any jobs left from a previous crash (no original WAMessage
14
+ // available, so replies are sent as plain messages, not quoted).
15
+ void replayPending(async (job, result) => {
16
+ await handleReply(job, result, {});
17
+ }).catch((err) => logger.error({ err }, 'replay failed'));
18
+ }
19
+ process.on('SIGINT', () => {
20
+ logger.info('SIGINT received, shutting down');
21
+ process.exit(0);
22
+ });
23
+ process.on('SIGTERM', () => {
24
+ logger.info('SIGTERM received, shutting down');
25
+ process.exit(0);
26
+ });
27
+ main().catch((err) => {
28
+ logger.error({ err }, 'fatal error during boot');
29
+ process.exit(1);
30
+ });
package/dist/logger.js ADDED
@@ -0,0 +1,7 @@
1
+ import pino from 'pino';
2
+ import { config } from './config.js';
3
+ export const logger = pino({
4
+ level: config.logging.level,
5
+ base: undefined,
6
+ timestamp: pino.stdTimeFunctions.isoTime,
7
+ });
@@ -0,0 +1,8 @@
1
+ const DIGEST_RE = /\[DIGEST:\s*([^\]]+)\]\s*$/i;
2
+ export function extractDigestFlag(reply) {
3
+ const match = reply.match(DIGEST_RE);
4
+ if (!match)
5
+ return { clean: reply, flag: null };
6
+ const clean = reply.slice(0, match.index).trimEnd();
7
+ return { clean, flag: match[1]?.trim() ?? '' };
8
+ }
@@ -0,0 +1,211 @@
1
+ import { spawn } from 'child_process';
2
+ import { config } from '../config.js';
3
+ import { logger } from '../logger.js';
4
+ import { logPrompt } from '../promptlog.js';
5
+ import { readLast } from '../store/messages.js';
6
+ import { readBrief, readProfile, setLastDigestedAt, writeBrief, writeProfile, } from './store.js';
7
+ /**
8
+ * Run a stateless Claude call to consolidate memory.
9
+ * Returns the new content Claude proposed.
10
+ */
11
+ async function spawnDigester(prompt) {
12
+ const args = [
13
+ '-p',
14
+ '--output-format',
15
+ 'json',
16
+ '--model',
17
+ config.claude.model,
18
+ ];
19
+ const startedAt = Date.now();
20
+ return new Promise((resolvePromise, rejectPromise) => {
21
+ const child = spawn('claude', args, {
22
+ stdio: ['pipe', 'pipe', 'pipe'],
23
+ cwd: process.cwd(),
24
+ });
25
+ let stdout = '';
26
+ let stderr = '';
27
+ let timedOut = false;
28
+ const timer = setTimeout(() => {
29
+ timedOut = true;
30
+ child.kill('SIGTERM');
31
+ }, config.claude.timeoutMs);
32
+ child.stdout.on('data', (chunk) => {
33
+ stdout += chunk.toString('utf-8');
34
+ });
35
+ child.stderr.on('data', (chunk) => {
36
+ stderr += chunk.toString('utf-8');
37
+ });
38
+ const logFail = (error) => void logPrompt({
39
+ ts: Math.floor(startedAt / 1000),
40
+ caller: 'digester',
41
+ args,
42
+ input: prompt,
43
+ error,
44
+ durationMs: Date.now() - startedAt,
45
+ });
46
+ child.on('error', (err) => {
47
+ clearTimeout(timer);
48
+ logFail(`spawn failed: ${err.message}`);
49
+ rejectPromise(new Error(`digester spawn failed: ${err.message}`));
50
+ });
51
+ child.on('close', (code) => {
52
+ clearTimeout(timer);
53
+ if (timedOut) {
54
+ logFail('timed out');
55
+ return rejectPromise(new Error('digester timed out'));
56
+ }
57
+ if (code !== 0) {
58
+ logFail(`exit ${code}: ${stderr.slice(0, 300)}`);
59
+ return rejectPromise(new Error(`digester exit ${code}: ${stderr.slice(0, 300)}`));
60
+ }
61
+ try {
62
+ const parsed = JSON.parse(stdout);
63
+ if (parsed.is_error || parsed.subtype !== 'success' || !parsed.result) {
64
+ logFail(`bad output: ${parsed.result ?? stderr.slice(0, 200)}`);
65
+ return rejectPromise(new Error(`digester bad output: ${parsed.result ?? stderr.slice(0, 200)}`));
66
+ }
67
+ const output = parsed.result.trim();
68
+ void logPrompt({
69
+ ts: Math.floor(startedAt / 1000),
70
+ caller: 'digester',
71
+ args,
72
+ input: prompt,
73
+ output,
74
+ durationMs: Date.now() - startedAt,
75
+ });
76
+ resolvePromise(output);
77
+ }
78
+ catch (err) {
79
+ logFail(`parse failed: ${err.message}`);
80
+ rejectPromise(new Error(`digester parse failed: ${err.message}`));
81
+ }
82
+ });
83
+ child.stdin.write(prompt);
84
+ child.stdin.end();
85
+ });
86
+ }
87
+ function formatMessagesForDigest(messages) {
88
+ return messages
89
+ .map((m) => {
90
+ const date = new Date(m.timestamp * 1000)
91
+ .toISOString()
92
+ .slice(0, 16)
93
+ .replace('T', ' ');
94
+ const who = m.direction === 'out' ? 'assistant' : m.pushName || m.senderNumber || 'user';
95
+ return `${who} (${date}): ${m.text}`;
96
+ })
97
+ .join('\n');
98
+ }
99
+ function profilePrompt(params) {
100
+ const { number, current, messages, reason } = params;
101
+ const personMessages = messages.filter((m) => m.senderNumber === number && m.direction === 'in');
102
+ const replyMessages = messages.filter((m) => m.direction === 'out');
103
+ const lines = [
104
+ `You are consolidating the long-term profile for a WhatsApp contact.`,
105
+ `Contact number: ${number}`,
106
+ ``,
107
+ `Current profile (may be empty):`,
108
+ current || '(empty)',
109
+ ``,
110
+ reason ? `Reason this update was triggered: ${reason}` : '',
111
+ ``,
112
+ `Recent messages from this person:`,
113
+ personMessages.length
114
+ ? formatMessagesForDigest(personMessages)
115
+ : '(none in window)',
116
+ ``,
117
+ `Recent bot replies (for context):`,
118
+ replyMessages.length
119
+ ? formatMessagesForDigest(replyMessages.slice(-10))
120
+ : '(none)',
121
+ ``,
122
+ `Rewrite the profile in markdown. Structure:`,
123
+ `# <Name if known, else number>`,
124
+ `## Facts`,
125
+ `## Preferences`,
126
+ `## Patterns`,
127
+ `## Recent context`,
128
+ ``,
129
+ `Rules:`,
130
+ `- Keep under 500 tokens.`,
131
+ `- Only append durable observations. Merge redundant items.`,
132
+ `- Remove nothing unless clearly outdated.`,
133
+ `- Do not invent facts.`,
134
+ `- Output ONLY the new markdown profile content. No preamble, no explanation.`,
135
+ ];
136
+ return lines.filter(Boolean).join('\n');
137
+ }
138
+ function briefPrompt(params) {
139
+ const { jid, current, messages, reason } = params;
140
+ const lines = [
141
+ `You are consolidating the long-term brief for a WhatsApp chat.`,
142
+ `Chat JID: ${jid}`,
143
+ ``,
144
+ `Current brief (may be empty):`,
145
+ current || '(empty)',
146
+ ``,
147
+ reason ? `Reason this update was triggered: ${reason}` : '',
148
+ ``,
149
+ `Recent messages in this chat:`,
150
+ messages.length ? formatMessagesForDigest(messages) : '(none in window)',
151
+ ``,
152
+ `Rewrite the brief in markdown. Structure:`,
153
+ `# <Chat name>`,
154
+ `## Purpose`,
155
+ `## Tone and norms`,
156
+ `## Recent topics`,
157
+ `## Decisions and open questions`,
158
+ ``,
159
+ `Rules:`,
160
+ `- Keep under 500 tokens.`,
161
+ `- Focus on durable context useful for future replies.`,
162
+ `- Do not include raw messages or copy verbatim.`,
163
+ `- Output ONLY the new markdown brief. No preamble.`,
164
+ ];
165
+ return lines.filter(Boolean).join('\n');
166
+ }
167
+ export async function runDigest(params) {
168
+ const { jid, number, reason } = params;
169
+ const now = Math.floor(Date.now() / 1000);
170
+ const messages = await readLast(jid, config.memory.maxHistoryForDigest);
171
+ if (!messages.length) {
172
+ logger.info({ jid }, 'digest skipped: no messages in window');
173
+ return;
174
+ }
175
+ // Update brief for the jid
176
+ try {
177
+ const current = readBrief(jid);
178
+ const prompt = briefPrompt({
179
+ jid,
180
+ current,
181
+ messages,
182
+ reason: reason ?? null,
183
+ });
184
+ const next = await spawnDigester(prompt);
185
+ writeBrief(jid, next + '\n');
186
+ setLastDigestedAt('jid', jid, now);
187
+ logger.info({ jid, chars: next.length }, 'brief updated');
188
+ }
189
+ catch (err) {
190
+ logger.error({ err, jid }, 'brief digest failed');
191
+ }
192
+ // Update profile for the specific person if provided
193
+ if (number) {
194
+ try {
195
+ const current = readProfile(number);
196
+ const prompt = profilePrompt({
197
+ number,
198
+ current,
199
+ messages,
200
+ reason: reason ?? null,
201
+ });
202
+ const next = await spawnDigester(prompt);
203
+ writeProfile(number, next + '\n');
204
+ setLastDigestedAt('person', number, now);
205
+ logger.info({ number, chars: next.length }, 'profile updated');
206
+ }
207
+ catch (err) {
208
+ logger.error({ err, number }, 'profile digest failed');
209
+ }
210
+ }
211
+ }
@@ -0,0 +1,100 @@
1
+ const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
2
+ export function parseFrontmatter(content) {
3
+ const m = content.match(FM_RE);
4
+ if (!m)
5
+ return { data: {}, body: content };
6
+ const yaml = m[1] ?? '';
7
+ const body = m[2] ?? '';
8
+ const data = {};
9
+ for (const rawLine of yaml.split(/\r?\n/)) {
10
+ const line = rawLine.trim();
11
+ if (!line || line.startsWith('#'))
12
+ continue;
13
+ const kv = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/);
14
+ if (!kv)
15
+ continue;
16
+ const key = kv[1];
17
+ data[key] = parseValue((kv[2] ?? '').trim());
18
+ }
19
+ return { data, body };
20
+ }
21
+ function parseValue(raw) {
22
+ if (raw === '' || raw === 'null' || raw === '~')
23
+ return null;
24
+ if (raw === 'true')
25
+ return true;
26
+ if (raw === 'false')
27
+ return false;
28
+ if (raw.startsWith('[') && raw.endsWith(']')) {
29
+ const inner = raw.slice(1, -1).trim();
30
+ if (!inner)
31
+ return [];
32
+ return splitArgs(inner).map(stripQuotes);
33
+ }
34
+ if ((raw.startsWith('"') && raw.endsWith('"')) ||
35
+ (raw.startsWith("'") && raw.endsWith("'"))) {
36
+ return stripQuotes(raw);
37
+ }
38
+ if (/^-?\d+(\.\d+)?$/.test(raw))
39
+ return Number(raw);
40
+ return raw;
41
+ }
42
+ function splitArgs(inner) {
43
+ const out = [];
44
+ let current = '';
45
+ let quote = null;
46
+ for (const ch of inner) {
47
+ if (quote) {
48
+ current += ch;
49
+ if (ch === quote)
50
+ quote = null;
51
+ continue;
52
+ }
53
+ if (ch === '"' || ch === "'") {
54
+ quote = ch;
55
+ current += ch;
56
+ continue;
57
+ }
58
+ if (ch === ',') {
59
+ out.push(current.trim());
60
+ current = '';
61
+ continue;
62
+ }
63
+ current += ch;
64
+ }
65
+ if (current.trim())
66
+ out.push(current.trim());
67
+ return out;
68
+ }
69
+ function stripQuotes(s) {
70
+ if ((s.startsWith('"') && s.endsWith('"')) ||
71
+ (s.startsWith("'") && s.endsWith("'"))) {
72
+ return s.slice(1, -1);
73
+ }
74
+ return s;
75
+ }
76
+ export function serializeFrontmatter(data, body) {
77
+ const lines = ['---'];
78
+ for (const [k, v] of Object.entries(data)) {
79
+ lines.push(`${k}: ${serializeValue(v)}`);
80
+ }
81
+ lines.push('---');
82
+ lines.push('');
83
+ lines.push(body.trimStart());
84
+ return lines.join('\n');
85
+ }
86
+ function serializeValue(v) {
87
+ if (v === null)
88
+ return 'null';
89
+ if (typeof v === 'boolean')
90
+ return v ? 'true' : 'false';
91
+ if (typeof v === 'number')
92
+ return String(v);
93
+ if (Array.isArray(v)) {
94
+ return '[' + v.map((x) => JSON.stringify(x)).join(', ') + ']';
95
+ }
96
+ // simple strings unquoted if safe
97
+ if (/^[A-Za-z0-9 _\-./:@]+$/.test(v))
98
+ return v;
99
+ return JSON.stringify(v);
100
+ }