namnam-skills 1.0.0 → 1.0.1

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,447 @@
1
+ /**
2
+ * Conversation Management Utilities
3
+ *
4
+ * Handles saving, loading, and managing conversation context
5
+ * for the @conversation feature
6
+ */
7
+
8
+ import fs from 'fs-extra';
9
+ import path from 'path';
10
+ import crypto from 'crypto';
11
+
12
+ // Default conversation directory
13
+ const CONV_DIR_NAME = 'conversations';
14
+ const INDEX_FILE = 'index.json';
15
+ const META_FILE = 'meta.json';
16
+ const CONTEXT_FILE = 'context.md';
17
+ const FULL_LOG_FILE = 'full.md';
18
+
19
+ /**
20
+ * Validate conversation options
21
+ */
22
+ function validateConversationOptions(options) {
23
+ const errors = [];
24
+
25
+ if (options.title !== undefined && typeof options.title !== 'string') {
26
+ errors.push('title must be a string');
27
+ }
28
+
29
+ if (options.summary !== undefined && typeof options.summary !== 'string') {
30
+ errors.push('summary must be a string');
31
+ }
32
+
33
+ if (options.context !== undefined && typeof options.context !== 'string') {
34
+ errors.push('context must be a string');
35
+ }
36
+
37
+ if (options.fullLog !== undefined && typeof options.fullLog !== 'string') {
38
+ errors.push('fullLog must be a string');
39
+ }
40
+
41
+ if (options.tags !== undefined && !Array.isArray(options.tags)) {
42
+ errors.push('tags must be an array');
43
+ }
44
+
45
+ if (errors.length > 0) {
46
+ throw new Error(`Invalid conversation options: ${errors.join(', ')}`);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Escape XML special characters
52
+ */
53
+ function escapeXml(str) {
54
+ if (!str) return '';
55
+ return str
56
+ .replace(/&/g, '&')
57
+ .replace(/</g, '&lt;')
58
+ .replace(/>/g, '&gt;')
59
+ .replace(/"/g, '&quot;')
60
+ .replace(/'/g, '&apos;');
61
+ }
62
+
63
+ /**
64
+ * Get the conversations directory path
65
+ */
66
+ export function getConversationsDir(cwd = process.cwd()) {
67
+ return path.join(cwd, '.claude', CONV_DIR_NAME);
68
+ }
69
+
70
+ /**
71
+ * Get the index file path
72
+ */
73
+ export function getIndexPath(cwd = process.cwd()) {
74
+ return path.join(getConversationsDir(cwd), INDEX_FILE);
75
+ }
76
+
77
+ /**
78
+ * Generate a unique conversation ID
79
+ */
80
+ export function generateConvId() {
81
+ const timestamp = Date.now().toString(36);
82
+ const random = crypto.randomBytes(3).toString('hex');
83
+ return `conv_${timestamp}_${random}`;
84
+ }
85
+
86
+ /**
87
+ * Generate a short ID for easy reference
88
+ */
89
+ export function generateShortId() {
90
+ return crypto.randomBytes(4).toString('hex');
91
+ }
92
+
93
+ /**
94
+ * Initialize conversations directory
95
+ */
96
+ export async function initConversations(cwd = process.cwd()) {
97
+ const convDir = getConversationsDir(cwd);
98
+ const indexPath = getIndexPath(cwd);
99
+
100
+ await fs.ensureDir(convDir);
101
+
102
+ if (!(await fs.pathExists(indexPath))) {
103
+ await fs.writeJson(indexPath, {
104
+ version: '1.0.0',
105
+ conversations: [],
106
+ lastUpdated: new Date().toISOString()
107
+ }, { spaces: 2 });
108
+ }
109
+
110
+ return convDir;
111
+ }
112
+
113
+ /**
114
+ * Load the conversation index
115
+ */
116
+ export async function loadIndex(cwd = process.cwd()) {
117
+ const indexPath = getIndexPath(cwd);
118
+
119
+ if (!(await fs.pathExists(indexPath))) {
120
+ return { version: '1.0.0', conversations: [], lastUpdated: null };
121
+ }
122
+
123
+ return await fs.readJson(indexPath);
124
+ }
125
+
126
+ /**
127
+ * Save the conversation index
128
+ */
129
+ export async function saveIndex(index, cwd = process.cwd()) {
130
+ const indexPath = getIndexPath(cwd);
131
+ index.lastUpdated = new Date().toISOString();
132
+ await fs.writeJson(indexPath, index, { spaces: 2 });
133
+ }
134
+
135
+ /**
136
+ * Save a new conversation
137
+ */
138
+ export async function saveConversation(options, cwd = process.cwd()) {
139
+ validateConversationOptions(options);
140
+
141
+ const {
142
+ title,
143
+ summary,
144
+ context,
145
+ fullLog = null,
146
+ tags = [],
147
+ metadata = {}
148
+ } = options;
149
+
150
+ await initConversations(cwd);
151
+
152
+ const id = generateConvId();
153
+ const shortId = generateShortId();
154
+ const convDir = path.join(getConversationsDir(cwd), id);
155
+
156
+ await fs.ensureDir(convDir);
157
+
158
+ // Create metadata file
159
+ const meta = {
160
+ id,
161
+ shortId,
162
+ title: title || 'Untitled Conversation',
163
+ summary: summary || '',
164
+ tags,
165
+ createdAt: new Date().toISOString(),
166
+ updatedAt: new Date().toISOString(),
167
+ ...metadata
168
+ };
169
+
170
+ await fs.writeJson(path.join(convDir, META_FILE), meta, { spaces: 2 });
171
+
172
+ // Create context file (loadable summary)
173
+ if (context) {
174
+ await fs.writeFile(path.join(convDir, CONTEXT_FILE), context);
175
+ }
176
+
177
+ // Create full log if provided
178
+ if (fullLog) {
179
+ await fs.writeFile(path.join(convDir, FULL_LOG_FILE), fullLog);
180
+ }
181
+
182
+ // Update index
183
+ const index = await loadIndex(cwd);
184
+ index.conversations.push({
185
+ id,
186
+ shortId,
187
+ title: meta.title,
188
+ summary: meta.summary,
189
+ tags,
190
+ createdAt: meta.createdAt
191
+ });
192
+ await saveIndex(index, cwd);
193
+
194
+ return { id, shortId, path: convDir };
195
+ }
196
+
197
+ /**
198
+ * Get a conversation by ID (full or short)
199
+ */
200
+ export async function getConversation(convId, cwd = process.cwd()) {
201
+ const index = await loadIndex(cwd);
202
+
203
+ // Find by full ID or short ID
204
+ const entry = index.conversations.find(
205
+ c => c.id === convId || c.shortId === convId || c.id.includes(convId)
206
+ );
207
+
208
+ if (!entry) {
209
+ return null;
210
+ }
211
+
212
+ const convDir = path.join(getConversationsDir(cwd), entry.id);
213
+
214
+ if (!(await fs.pathExists(convDir))) {
215
+ return null;
216
+ }
217
+
218
+ const meta = await fs.readJson(path.join(convDir, META_FILE));
219
+
220
+ let context = null;
221
+ let fullLog = null;
222
+
223
+ const contextPath = path.join(convDir, CONTEXT_FILE);
224
+ const fullPath = path.join(convDir, FULL_LOG_FILE);
225
+
226
+ if (await fs.pathExists(contextPath)) {
227
+ context = await fs.readFile(contextPath, 'utf-8');
228
+ }
229
+
230
+ if (await fs.pathExists(fullPath)) {
231
+ fullLog = await fs.readFile(fullPath, 'utf-8');
232
+ }
233
+
234
+ return { ...meta, context, fullLog };
235
+ }
236
+
237
+ /**
238
+ * List all conversations
239
+ */
240
+ export async function listConversations(options = {}, cwd = process.cwd()) {
241
+ const { limit = 20, tag = null, search = null } = options;
242
+ const index = await loadIndex(cwd);
243
+
244
+ let conversations = index.conversations || [];
245
+
246
+ // Filter by tag
247
+ if (tag) {
248
+ conversations = conversations.filter(c =>
249
+ c.tags && c.tags.includes(tag)
250
+ );
251
+ }
252
+
253
+ // Filter by search term
254
+ if (search) {
255
+ const term = search.toLowerCase();
256
+ conversations = conversations.filter(c =>
257
+ c.title.toLowerCase().includes(term) ||
258
+ c.summary.toLowerCase().includes(term) ||
259
+ c.id.includes(term) ||
260
+ c.shortId.includes(term)
261
+ );
262
+ }
263
+
264
+ // Sort by date (newest first)
265
+ conversations.sort((a, b) =>
266
+ new Date(b.createdAt) - new Date(a.createdAt)
267
+ );
268
+
269
+ // Limit results
270
+ if (limit > 0) {
271
+ conversations = conversations.slice(0, limit);
272
+ }
273
+
274
+ return conversations;
275
+ }
276
+
277
+ /**
278
+ * Update a conversation
279
+ */
280
+ export async function updateConversation(convId, updates, cwd = process.cwd()) {
281
+ validateConversationOptions(updates);
282
+
283
+ const conversation = await getConversation(convId, cwd);
284
+
285
+ if (!conversation) {
286
+ return null;
287
+ }
288
+
289
+ const convDir = path.join(getConversationsDir(cwd), conversation.id);
290
+
291
+ // Store context and fullLog before processing
292
+ const newContext = updates.context;
293
+ const newFullLog = updates.fullLog;
294
+
295
+ // Prepare metadata updates (exclude file content fields)
296
+ const metaUpdates = { ...updates };
297
+ delete metaUpdates.id;
298
+ delete metaUpdates.context;
299
+ delete metaUpdates.fullLog;
300
+
301
+ // Update metadata
302
+ const meta = await fs.readJson(path.join(convDir, META_FILE));
303
+ const updatedMeta = {
304
+ ...meta,
305
+ ...metaUpdates,
306
+ updatedAt: new Date().toISOString()
307
+ };
308
+
309
+ await fs.writeJson(path.join(convDir, META_FILE), updatedMeta, { spaces: 2 });
310
+
311
+ // Update context if provided
312
+ if (newContext !== undefined) {
313
+ await fs.writeFile(path.join(convDir, CONTEXT_FILE), newContext);
314
+ }
315
+
316
+ // Update full log if provided
317
+ if (newFullLog !== undefined) {
318
+ await fs.writeFile(path.join(convDir, FULL_LOG_FILE), newFullLog);
319
+ }
320
+
321
+ // Update index
322
+ const index = await loadIndex(cwd);
323
+ const idx = index.conversations.findIndex(c => c.id === conversation.id);
324
+ if (idx !== -1) {
325
+ index.conversations[idx] = {
326
+ ...index.conversations[idx],
327
+ title: updatedMeta.title,
328
+ summary: updatedMeta.summary,
329
+ tags: updatedMeta.tags
330
+ };
331
+ await saveIndex(index, cwd);
332
+ }
333
+
334
+ return updatedMeta;
335
+ }
336
+
337
+ /**
338
+ * Delete a conversation
339
+ */
340
+ export async function deleteConversation(convId, cwd = process.cwd()) {
341
+ const conversation = await getConversation(convId, cwd);
342
+
343
+ if (!conversation) {
344
+ return false;
345
+ }
346
+
347
+ const convDir = path.join(getConversationsDir(cwd), conversation.id);
348
+
349
+ // Remove directory
350
+ await fs.remove(convDir);
351
+
352
+ // Update index
353
+ const index = await loadIndex(cwd);
354
+ index.conversations = index.conversations.filter(c => c.id !== conversation.id);
355
+ await saveIndex(index, cwd);
356
+
357
+ return true;
358
+ }
359
+
360
+ /**
361
+ * Export conversation to a single markdown file
362
+ */
363
+ export async function exportConversation(convId, outputPath, cwd = process.cwd()) {
364
+ const conversation = await getConversation(convId, cwd);
365
+
366
+ if (!conversation) {
367
+ return null;
368
+ }
369
+
370
+ const content = `# ${conversation.title}
371
+
372
+ **ID:** ${conversation.shortId}
373
+ **Created:** ${conversation.createdAt}
374
+ **Tags:** ${conversation.tags?.join(', ') || 'none'}
375
+
376
+ ## Summary
377
+
378
+ ${conversation.summary || 'No summary available.'}
379
+
380
+ ## Context
381
+
382
+ ${conversation.context || 'No context available.'}
383
+
384
+ ${conversation.fullLog ? `## Full Log\n\n${conversation.fullLog}` : ''}
385
+ `;
386
+
387
+ await fs.writeFile(outputPath, content);
388
+ return outputPath;
389
+ }
390
+
391
+ /**
392
+ * Get conversation context for AI consumption
393
+ * This is what gets loaded when @conversation:id is used
394
+ */
395
+ export async function getConversationContext(convId, cwd = process.cwd()) {
396
+ const conversation = await getConversation(convId, cwd);
397
+
398
+ if (!conversation) {
399
+ return null;
400
+ }
401
+
402
+ const id = escapeXml(conversation.shortId);
403
+ const title = escapeXml(conversation.title);
404
+ const content = conversation.context || conversation.summary || 'No context available.';
405
+
406
+ return `
407
+ <conversation-context id="${id}" title="${title}">
408
+ ${content}
409
+ </conversation-context>
410
+ `.trim();
411
+ }
412
+
413
+ /**
414
+ * Parse @conversation references from text
415
+ */
416
+ export function parseConversationRefs(text) {
417
+ const pattern = /@conversation[:\s]+([a-zA-Z0-9_-]+)/gi;
418
+ const matches = [];
419
+ let match;
420
+
421
+ while ((match = pattern.exec(text)) !== null) {
422
+ matches.push(match[1]);
423
+ }
424
+
425
+ return [...new Set(matches)]; // Remove duplicates
426
+ }
427
+
428
+ /**
429
+ * Resolve all @conversation references in text
430
+ */
431
+ export async function resolveConversationRefs(text, cwd = process.cwd()) {
432
+ const refs = parseConversationRefs(text);
433
+ const contexts = [];
434
+
435
+ for (const ref of refs) {
436
+ const context = await getConversationContext(ref, cwd);
437
+ if (context) {
438
+ contexts.push(context);
439
+ }
440
+ }
441
+
442
+ return {
443
+ refs,
444
+ contexts,
445
+ combined: contexts.join('\n\n')
446
+ };
447
+ }