gyrus 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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +273 -0
  3. package/package.json +78 -0
  4. package/src/commands/adr.ts +482 -0
  5. package/src/commands/doctor.ts +263 -0
  6. package/src/commands/init.ts +293 -0
  7. package/src/commands/knowledge.ts +446 -0
  8. package/src/commands/list.ts +51 -0
  9. package/src/commands/mcp.ts +94 -0
  10. package/src/commands/use.ts +65 -0
  11. package/src/config/index.ts +262 -0
  12. package/src/config/schema.ts +139 -0
  13. package/src/formatters/adr.ts +295 -0
  14. package/src/formatters/index.ts +44 -0
  15. package/src/formatters/knowledge.ts +282 -0
  16. package/src/formatters/workspace.ts +149 -0
  17. package/src/index.ts +153 -0
  18. package/src/operations/adr.ts +312 -0
  19. package/src/operations/index.ts +58 -0
  20. package/src/operations/knowledge.ts +324 -0
  21. package/src/operations/types.ts +199 -0
  22. package/src/operations/workspace.ts +108 -0
  23. package/src/server/mcp-stdio.ts +526 -0
  24. package/src/services/adr.ts +511 -0
  25. package/src/services/knowledge.ts +516 -0
  26. package/src/services/markdown.ts +155 -0
  27. package/src/services/workspace.ts +377 -0
  28. package/src/templates/knowledge/README.md +199 -0
  29. package/src/tools/adr-create.ts +99 -0
  30. package/src/tools/adr-list.ts +72 -0
  31. package/src/tools/adr-read.ts +54 -0
  32. package/src/tools/adr-search.ts +79 -0
  33. package/src/tools/adr-update.ts +95 -0
  34. package/src/tools/gyrus-list.ts +41 -0
  35. package/src/tools/gyrus-switch.ts +45 -0
  36. package/src/tools/index.ts +91 -0
  37. package/src/tools/knowledge-create.ts +75 -0
  38. package/src/tools/knowledge-list.ts +60 -0
  39. package/src/tools/knowledge-read.ts +62 -0
  40. package/src/tools/knowledge-search.ts +65 -0
  41. package/src/tools/knowledge-update.ts +76 -0
  42. package/src/types/index.ts +343 -0
  43. package/tsconfig.json +26 -0
@@ -0,0 +1,516 @@
1
+ /**
2
+ * Knowledge service for file operations
3
+ * Handles reading, writing, searching, and listing knowledge notes
4
+ */
5
+
6
+ import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
7
+ import { join, relative } from "node:path";
8
+ import { existsSync } from "node:fs";
9
+ import type {
10
+ KnowledgeNote,
11
+ KnowledgeCategory,
12
+ CreateNoteInput,
13
+ UpdateNoteInput,
14
+ SearchOptions,
15
+ ListOptions,
16
+ OperationResult,
17
+ } from "../types/index.ts";
18
+ import {
19
+ parseMarkdown,
20
+ serializeMarkdown,
21
+ createMarkdownContent,
22
+ getCategoryFolder,
23
+ getTodayDate,
24
+ } from "./markdown.ts";
25
+
26
+ /**
27
+ * Knowledge service class
28
+ * Manages all operations on the knowledge notes system
29
+ */
30
+ export class KnowledgeService {
31
+ private knowledgePath: string;
32
+ private cache: Map<string, KnowledgeNote> = new Map();
33
+ private cacheInitialized = false;
34
+
35
+ constructor(knowledgePath: string) {
36
+ this.knowledgePath = knowledgePath;
37
+ }
38
+
39
+ /**
40
+ * Get the knowledge path
41
+ */
42
+ getPath(): string {
43
+ return this.knowledgePath;
44
+ }
45
+
46
+ /**
47
+ * Initialize the cache by reading all notes
48
+ */
49
+ async initialize(): Promise<void> {
50
+ if (this.cacheInitialized) return;
51
+ await this.refreshCache();
52
+ this.cacheInitialized = true;
53
+ }
54
+
55
+ /**
56
+ * Refresh the cache by re-reading all notes from disk
57
+ */
58
+ async refreshCache(): Promise<void> {
59
+ this.cache.clear();
60
+ const notes = await this.readAllNotes();
61
+ for (const note of notes) {
62
+ this.cache.set(note.id, note);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Read all markdown files from the knowledge directory
68
+ */
69
+ private async readAllNotes(): Promise<KnowledgeNote[]> {
70
+ const notes: KnowledgeNote[] = [];
71
+ const categories: KnowledgeCategory[] = [
72
+ "projects",
73
+ "patterns",
74
+ "tools",
75
+ "gotchas",
76
+ "decisions",
77
+ ];
78
+
79
+ for (const category of categories) {
80
+ const categoryPath = join(this.knowledgePath, category);
81
+ if (!existsSync(categoryPath)) continue;
82
+
83
+ const files = await readdir(categoryPath);
84
+ for (const file of files) {
85
+ if (!file.endsWith(".md")) continue;
86
+
87
+ const filePath = join(categoryPath, file);
88
+ const content = await readFile(filePath, "utf-8");
89
+ const relativePath = relative(this.knowledgePath, filePath);
90
+ const note = parseMarkdown(content, relativePath);
91
+
92
+ if (note) {
93
+ notes.push(note);
94
+ }
95
+ }
96
+ }
97
+
98
+ return notes;
99
+ }
100
+
101
+ /**
102
+ * Get a note by ID
103
+ */
104
+ async getNote(id: string): Promise<KnowledgeNote | null> {
105
+ await this.initialize();
106
+ return this.cache.get(id) ?? null;
107
+ }
108
+
109
+ /**
110
+ * Get a note by path (relative to knowledge folder)
111
+ */
112
+ async getNoteByPath(path: string): Promise<KnowledgeNote | null> {
113
+ await this.initialize();
114
+
115
+ for (const note of this.cache.values()) {
116
+ if (note.path === path) {
117
+ return note;
118
+ }
119
+ }
120
+
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * List all notes with optional filtering
126
+ */
127
+ async listNotes(options?: ListOptions): Promise<KnowledgeNote[]> {
128
+ await this.initialize();
129
+
130
+ let notes = Array.from(this.cache.values());
131
+
132
+ if (options?.category) {
133
+ notes = notes.filter((note) => note.category === options.category);
134
+ }
135
+
136
+ if (options?.tags && options.tags.length > 0) {
137
+ notes = notes.filter((note) =>
138
+ options.tags!.some((tag) => note.tags.includes(tag))
139
+ );
140
+ }
141
+
142
+ // Sort by updated date (most recent first)
143
+ notes.sort((a, b) => b.updated.localeCompare(a.updated));
144
+
145
+ return notes;
146
+ }
147
+
148
+ /**
149
+ * Search notes by query, tags, or category
150
+ */
151
+ async searchNotes(options: SearchOptions): Promise<KnowledgeNote[]> {
152
+ await this.initialize();
153
+
154
+ let notes = Array.from(this.cache.values());
155
+
156
+ // Filter by category
157
+ if (options.category) {
158
+ notes = notes.filter((note) => note.category === options.category);
159
+ }
160
+
161
+ // Filter by tags (match any)
162
+ if (options.tags && options.tags.length > 0) {
163
+ notes = notes.filter((note) =>
164
+ options.tags!.some((tag) =>
165
+ note.tags.some((noteTag) =>
166
+ noteTag.toLowerCase().includes(tag.toLowerCase())
167
+ )
168
+ )
169
+ );
170
+ }
171
+
172
+ // Filter by text query (searches title and content)
173
+ if (options.query) {
174
+ const queryLower = options.query.toLowerCase();
175
+ notes = notes.filter(
176
+ (note) =>
177
+ note.title.toLowerCase().includes(queryLower) ||
178
+ note.content.toLowerCase().includes(queryLower) ||
179
+ note.id.toLowerCase().includes(queryLower) ||
180
+ (note.description?.toLowerCase().includes(queryLower) ?? false)
181
+ );
182
+ }
183
+
184
+ // Sort by relevance (title matches first, then by updated date)
185
+ if (options.query) {
186
+ const queryLower = options.query.toLowerCase();
187
+ notes.sort((a, b) => {
188
+ const aInTitle = a.title.toLowerCase().includes(queryLower) ? 1 : 0;
189
+ const bInTitle = b.title.toLowerCase().includes(queryLower) ? 1 : 0;
190
+ if (aInTitle !== bInTitle) return bInTitle - aInTitle;
191
+ return b.updated.localeCompare(a.updated);
192
+ });
193
+ } else {
194
+ notes.sort((a, b) => b.updated.localeCompare(a.updated));
195
+ }
196
+
197
+ return notes;
198
+ }
199
+
200
+ /**
201
+ * Create a new knowledge note
202
+ */
203
+ async createNote(input: CreateNoteInput): Promise<OperationResult> {
204
+ await this.initialize();
205
+
206
+ // Check if ID already exists
207
+ if (this.cache.has(input.id)) {
208
+ return {
209
+ success: false,
210
+ message: `Note with ID "${input.id}" already exists`,
211
+ };
212
+ }
213
+
214
+ // Validate ID format (kebab-case)
215
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(input.id)) {
216
+ return {
217
+ success: false,
218
+ message: "ID must be in kebab-case format (e.g., my-note-id)",
219
+ };
220
+ }
221
+
222
+ const today = getTodayDate();
223
+ const categoryFolder = getCategoryFolder(input.category);
224
+ const fileName = `${input.id}.md`;
225
+ const relativePath = join(categoryFolder, fileName);
226
+ const absolutePath = join(this.knowledgePath, relativePath);
227
+
228
+ // Ensure category directory exists
229
+ const categoryPath = join(this.knowledgePath, categoryFolder);
230
+ if (!existsSync(categoryPath)) {
231
+ await mkdir(categoryPath, { recursive: true });
232
+ }
233
+
234
+ // Create markdown content
235
+ const markdownContent = createMarkdownContent({
236
+ id: input.id,
237
+ title: input.title,
238
+ content: input.content,
239
+ created: today,
240
+ updated: today,
241
+ tags: input.tags,
242
+ related: input.related,
243
+ description: input.description,
244
+ });
245
+
246
+ // Write file
247
+ await writeFile(absolutePath, markdownContent, "utf-8");
248
+
249
+ // Create note object and add to cache
250
+ const note: KnowledgeNote = {
251
+ id: input.id,
252
+ title: input.title,
253
+ created: today,
254
+ updated: today,
255
+ tags: input.tags ?? [],
256
+ related: input.related ?? [],
257
+ description: input.description,
258
+ content: input.content,
259
+ path: relativePath,
260
+ category: input.category,
261
+ };
262
+
263
+ this.cache.set(input.id, note);
264
+
265
+ // Update index.md
266
+ await this.updateIndex();
267
+
268
+ return {
269
+ success: true,
270
+ message: `Created note "${input.title}" at ${relativePath}`,
271
+ note,
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Update an existing knowledge note
277
+ */
278
+ async updateNote(input: UpdateNoteInput): Promise<OperationResult> {
279
+ await this.initialize();
280
+
281
+ const existingNote = this.cache.get(input.id);
282
+ if (!existingNote) {
283
+ return {
284
+ success: false,
285
+ message: `Note with ID "${input.id}" not found`,
286
+ };
287
+ }
288
+
289
+ const today = getTodayDate();
290
+
291
+ // Merge updates with existing note
292
+ const updatedNote: KnowledgeNote = {
293
+ ...existingNote,
294
+ title: input.title ?? existingNote.title,
295
+ content: input.content ?? existingNote.content,
296
+ tags: input.tags ?? existingNote.tags,
297
+ related: input.related ?? existingNote.related,
298
+ description: input.description ?? existingNote.description,
299
+ updated: today,
300
+ };
301
+
302
+ // Serialize and write
303
+ const markdownContent = serializeMarkdown(updatedNote);
304
+ const absolutePath = join(this.knowledgePath, existingNote.path);
305
+ await writeFile(absolutePath, markdownContent, "utf-8");
306
+
307
+ // Update cache
308
+ this.cache.set(input.id, updatedNote);
309
+
310
+ // Update index.md if title or tags changed
311
+ if (input.title || input.tags) {
312
+ await this.updateIndex();
313
+ }
314
+
315
+ return {
316
+ success: true,
317
+ message: `Updated note "${updatedNote.title}"`,
318
+ note: updatedNote,
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Update the index.md file to reflect current notes
324
+ */
325
+ private async updateIndex(): Promise<void> {
326
+ const notes = Array.from(this.cache.values());
327
+
328
+ // Group notes by category
329
+ const byCategory: Record<string, KnowledgeNote[]> = {
330
+ projects: [],
331
+ patterns: [],
332
+ tools: [],
333
+ gotchas: [],
334
+ decisions: [],
335
+ };
336
+
337
+ for (const note of notes) {
338
+ if (note.category !== "root" && byCategory[note.category]) {
339
+ byCategory[note.category]!.push(note);
340
+ }
341
+ }
342
+
343
+ // Sort each category alphabetically by ID
344
+ for (const category of Object.keys(byCategory)) {
345
+ byCategory[category]!.sort((a, b) => a.id.localeCompare(b.id));
346
+ }
347
+
348
+ // Collect all tags
349
+ const tagCounts: Record<string, number> = {};
350
+ for (const note of notes) {
351
+ for (const tag of note.tags) {
352
+ tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
353
+ }
354
+ }
355
+
356
+ const today = getTodayDate();
357
+ const totalNotes = notes.length;
358
+
359
+ // Generate index content
360
+ const indexContent = `---
361
+ id: knowledge-index
362
+ title: Knowledge Index
363
+ created: ${today}
364
+ updated: ${today}
365
+ ---
366
+
367
+ # Knowledge Index
368
+
369
+ Master index of all knowledge notes. **Keep this file updated when adding or removing notes.**
370
+
371
+ ## Quick Stats
372
+
373
+ - Total Notes: ${totalNotes}
374
+ - Last Updated: ${today}
375
+
376
+ ## System Files
377
+
378
+ | File | Description |
379
+ |------|-------------|
380
+ | [README.md](README.md) | Purpose, guidelines, and templates |
381
+ | [index.md](index.md) | This file - master index |
382
+ | [instructions.md](instructions.md) | AI agent workflow instructions |
383
+
384
+ ---
385
+
386
+ ## Projects
387
+
388
+ Project-specific knowledge and insights.
389
+
390
+ | ID | Title | Tags | Updated |
391
+ |----|-------|------|---------|
392
+ ${this.formatCategoryTable(byCategory["projects"]!)}
393
+
394
+ ---
395
+
396
+ ## Patterns
397
+
398
+ Reusable patterns and solutions.
399
+
400
+ | ID | Title | Tags | Updated |
401
+ |----|-------|------|---------|
402
+ ${this.formatCategoryTable(byCategory["patterns"]!)}
403
+
404
+ ---
405
+
406
+ ## Tools
407
+
408
+ Tool and technology notes.
409
+
410
+ | ID | Title | Tags | Updated |
411
+ |----|-------|------|---------|
412
+ ${this.formatCategoryTable(byCategory["tools"]!)}
413
+
414
+ ---
415
+
416
+ ## Gotchas
417
+
418
+ Common pitfalls and their solutions.
419
+
420
+ | ID | Title | Tags | Updated |
421
+ |----|-------|------|---------|
422
+ ${this.formatCategoryTable(byCategory["gotchas"]!)}
423
+
424
+ ---
425
+
426
+ ## Decisions
427
+
428
+ Key decisions and their rationale.
429
+
430
+ | ID | Title | Tags | Updated |
431
+ |----|-------|------|---------|
432
+ ${this.formatCategoryTable(byCategory["decisions"]!)}
433
+
434
+ ---
435
+
436
+ ## Tags Index
437
+
438
+ Quick reference of all tags in use:
439
+
440
+ ${this.formatTagsIndex(tagCounts)}`;
441
+
442
+ const indexPath = join(this.knowledgePath, "index.md");
443
+ await writeFile(indexPath, indexContent, "utf-8");
444
+ }
445
+
446
+ /**
447
+ * Format a category's notes as a markdown table
448
+ */
449
+ private formatCategoryTable(notes: KnowledgeNote[]): string {
450
+ if (notes.length === 0) {
451
+ return "| - | No notes yet | - | - |";
452
+ }
453
+
454
+ return notes
455
+ .map((note) => {
456
+ const tags = note.tags.join(", ") || "-";
457
+ return `| [${note.id}](${note.path}) | ${note.title} | ${tags} | ${note.updated} |`;
458
+ })
459
+ .join("\n");
460
+ }
461
+
462
+ /**
463
+ * Format the tags index section
464
+ */
465
+ private formatTagsIndex(tagCounts: Record<string, number>): string {
466
+ const sortedTags = Object.entries(tagCounts).sort(([a], [b]) =>
467
+ a.localeCompare(b)
468
+ );
469
+
470
+ if (sortedTags.length === 0) {
471
+ return "No tags yet.";
472
+ }
473
+
474
+ return sortedTags
475
+ .map(([tag, count]) => `- \`${tag}\` (${count} note${count > 1 ? "s" : ""})`)
476
+ .join("\n");
477
+ }
478
+
479
+ /**
480
+ * Get all unique tags across all notes
481
+ */
482
+ async getAllTags(): Promise<string[]> {
483
+ await this.initialize();
484
+
485
+ const tags = new Set<string>();
486
+ for (const note of this.cache.values()) {
487
+ for (const tag of note.tags) {
488
+ tags.add(tag);
489
+ }
490
+ }
491
+
492
+ return Array.from(tags).sort();
493
+ }
494
+
495
+ /**
496
+ * Get all categories with note counts
497
+ */
498
+ async getCategoryCounts(): Promise<Record<KnowledgeCategory, number>> {
499
+ await this.initialize();
500
+
501
+ const counts: Record<KnowledgeCategory, number> = {
502
+ projects: 0,
503
+ patterns: 0,
504
+ tools: 0,
505
+ gotchas: 0,
506
+ decisions: 0,
507
+ root: 0,
508
+ };
509
+
510
+ for (const note of this.cache.values()) {
511
+ counts[note.category]++;
512
+ }
513
+
514
+ return counts;
515
+ }
516
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Markdown parsing utilities for Gyrus
3
+ * Handles frontmatter extraction and serialization
4
+ */
5
+
6
+ import matter from "gray-matter";
7
+ import type {
8
+ KnowledgeNote,
9
+ KnowledgeFrontmatter,
10
+ KnowledgeCategory,
11
+ } from "../types/index.ts";
12
+
13
+ /**
14
+ * Get today's date in YYYY-MM-DD format
15
+ */
16
+ export function getTodayDate(): string {
17
+ const now = new Date();
18
+ const year = now.getFullYear();
19
+ const month = String(now.getMonth() + 1).padStart(2, "0");
20
+ const day = String(now.getDate()).padStart(2, "0");
21
+ return `${year}-${month}-${day}`;
22
+ }
23
+
24
+ /**
25
+ * Get category folder name
26
+ */
27
+ export function getCategoryFolder(
28
+ category: Exclude<KnowledgeCategory, "root">
29
+ ): string {
30
+ return category;
31
+ }
32
+
33
+ /**
34
+ * Determine category from file path
35
+ */
36
+ export function getCategoryFromPath(path: string): KnowledgeCategory {
37
+ const parts = path.split("/");
38
+ if (parts.length < 2) {
39
+ return "root";
40
+ }
41
+
42
+ const folder = parts[0];
43
+ const validCategories: KnowledgeCategory[] = [
44
+ "projects",
45
+ "patterns",
46
+ "tools",
47
+ "gotchas",
48
+ "decisions",
49
+ ];
50
+
51
+ if (validCategories.includes(folder as KnowledgeCategory)) {
52
+ return folder as KnowledgeCategory;
53
+ }
54
+
55
+ return "root";
56
+ }
57
+
58
+ /**
59
+ * Parse a markdown file with YAML frontmatter into a KnowledgeNote
60
+ * Returns null if parsing fails or required fields are missing
61
+ */
62
+ export function parseMarkdown(
63
+ content: string,
64
+ relativePath: string
65
+ ): KnowledgeNote | null {
66
+ try {
67
+ const { data, content: markdownContent } = matter(content);
68
+ const frontmatter = data as Partial<KnowledgeFrontmatter>;
69
+
70
+ // Validate required fields
71
+ if (
72
+ !frontmatter.id ||
73
+ !frontmatter.title ||
74
+ !frontmatter.created ||
75
+ !frontmatter.updated
76
+ ) {
77
+ return null;
78
+ }
79
+
80
+ const category = getCategoryFromPath(relativePath);
81
+
82
+ return {
83
+ id: frontmatter.id,
84
+ title: frontmatter.title,
85
+ created: String(frontmatter.created),
86
+ updated: String(frontmatter.updated),
87
+ tags: frontmatter.tags ?? [],
88
+ related: frontmatter.related ?? [],
89
+ description: frontmatter.description,
90
+ content: markdownContent.trim(),
91
+ path: relativePath,
92
+ category,
93
+ };
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Serialize a KnowledgeNote back to markdown with frontmatter
101
+ */
102
+ export function serializeMarkdown(note: KnowledgeNote): string {
103
+ const frontmatter: KnowledgeFrontmatter = {
104
+ id: note.id,
105
+ title: note.title,
106
+ created: note.created,
107
+ updated: note.updated,
108
+ };
109
+
110
+ // Only include optional fields if they have values
111
+ if (note.tags.length > 0) {
112
+ frontmatter.tags = note.tags;
113
+ }
114
+ if (note.related.length > 0) {
115
+ frontmatter.related = note.related;
116
+ }
117
+ if (note.description) {
118
+ frontmatter.description = note.description;
119
+ }
120
+
121
+ return matter.stringify(note.content, frontmatter);
122
+ }
123
+
124
+ /**
125
+ * Create markdown content for a new note
126
+ */
127
+ export function createMarkdownContent(options: {
128
+ id: string;
129
+ title: string;
130
+ content: string;
131
+ created: string;
132
+ updated: string;
133
+ tags?: string[];
134
+ related?: string[];
135
+ description?: string;
136
+ }): string {
137
+ const frontmatter: Record<string, unknown> = {
138
+ id: options.id,
139
+ title: options.title,
140
+ created: options.created,
141
+ updated: options.updated,
142
+ };
143
+
144
+ if (options.tags && options.tags.length > 0) {
145
+ frontmatter.tags = options.tags;
146
+ }
147
+ if (options.related && options.related.length > 0) {
148
+ frontmatter.related = options.related;
149
+ }
150
+ if (options.description) {
151
+ frontmatter.description = options.description;
152
+ }
153
+
154
+ return matter.stringify(options.content, frontmatter);
155
+ }