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.
- package/package.json +2 -1
- package/src/cli.js +544 -0
- package/src/conversation.js +447 -0
- package/src/indexer.js +793 -0
- package/src/templates/core/namnam.md +493 -237
- package/src/templates/platforms/AGENTS.md +47 -0
- package/src/templates/core/code-review.md +0 -70
- package/src/templates/core/git-commit.md +0 -57
- package/src/templates/core/git-push.md +0 -53
- package/src/templates/core/git-status.md +0 -48
- package/src/templates/core/validate-and-fix.md +0 -69
|
@@ -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, '<')
|
|
58
|
+
.replace(/>/g, '>')
|
|
59
|
+
.replace(/"/g, '"')
|
|
60
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|