@synth-coder/memhub 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 (104) hide show
  1. package/.eslintrc.cjs +46 -0
  2. package/.github/workflows/ci.yml +74 -0
  3. package/.iflow/commands/opsx-apply.md +152 -0
  4. package/.iflow/commands/opsx-archive.md +157 -0
  5. package/.iflow/commands/opsx-explore.md +173 -0
  6. package/.iflow/commands/opsx-propose.md +106 -0
  7. package/.iflow/skills/openspec-apply-change/SKILL.md +156 -0
  8. package/.iflow/skills/openspec-archive-change/SKILL.md +114 -0
  9. package/.iflow/skills/openspec-explore/SKILL.md +288 -0
  10. package/.iflow/skills/openspec-propose/SKILL.md +110 -0
  11. package/.prettierrc +11 -0
  12. package/README.md +171 -0
  13. package/README.zh-CN.md +169 -0
  14. package/dist/src/contracts/index.d.ts +7 -0
  15. package/dist/src/contracts/index.d.ts.map +1 -0
  16. package/dist/src/contracts/index.js +10 -0
  17. package/dist/src/contracts/index.js.map +1 -0
  18. package/dist/src/contracts/mcp.d.ts +194 -0
  19. package/dist/src/contracts/mcp.d.ts.map +1 -0
  20. package/dist/src/contracts/mcp.js +112 -0
  21. package/dist/src/contracts/mcp.js.map +1 -0
  22. package/dist/src/contracts/schemas.d.ts +1153 -0
  23. package/dist/src/contracts/schemas.d.ts.map +1 -0
  24. package/dist/src/contracts/schemas.js +246 -0
  25. package/dist/src/contracts/schemas.js.map +1 -0
  26. package/dist/src/contracts/types.d.ts +328 -0
  27. package/dist/src/contracts/types.d.ts.map +1 -0
  28. package/dist/src/contracts/types.js +30 -0
  29. package/dist/src/contracts/types.js.map +1 -0
  30. package/dist/src/index.d.ts +8 -0
  31. package/dist/src/index.d.ts.map +1 -0
  32. package/dist/src/index.js +8 -0
  33. package/dist/src/index.js.map +1 -0
  34. package/dist/src/server/index.d.ts +5 -0
  35. package/dist/src/server/index.d.ts.map +1 -0
  36. package/dist/src/server/index.js +5 -0
  37. package/dist/src/server/index.js.map +1 -0
  38. package/dist/src/server/mcp-server.d.ts +80 -0
  39. package/dist/src/server/mcp-server.d.ts.map +1 -0
  40. package/dist/src/server/mcp-server.js +263 -0
  41. package/dist/src/server/mcp-server.js.map +1 -0
  42. package/dist/src/services/index.d.ts +5 -0
  43. package/dist/src/services/index.d.ts.map +1 -0
  44. package/dist/src/services/index.js +5 -0
  45. package/dist/src/services/index.js.map +1 -0
  46. package/dist/src/services/memory-service.d.ts +105 -0
  47. package/dist/src/services/memory-service.d.ts.map +1 -0
  48. package/dist/src/services/memory-service.js +447 -0
  49. package/dist/src/services/memory-service.js.map +1 -0
  50. package/dist/src/storage/frontmatter-parser.d.ts +69 -0
  51. package/dist/src/storage/frontmatter-parser.d.ts.map +1 -0
  52. package/dist/src/storage/frontmatter-parser.js +207 -0
  53. package/dist/src/storage/frontmatter-parser.js.map +1 -0
  54. package/dist/src/storage/index.d.ts +6 -0
  55. package/dist/src/storage/index.d.ts.map +1 -0
  56. package/dist/src/storage/index.js +6 -0
  57. package/dist/src/storage/index.js.map +1 -0
  58. package/dist/src/storage/markdown-storage.d.ts +76 -0
  59. package/dist/src/storage/markdown-storage.d.ts.map +1 -0
  60. package/dist/src/storage/markdown-storage.js +193 -0
  61. package/dist/src/storage/markdown-storage.js.map +1 -0
  62. package/dist/src/utils/index.d.ts +5 -0
  63. package/dist/src/utils/index.d.ts.map +1 -0
  64. package/dist/src/utils/index.js +5 -0
  65. package/dist/src/utils/index.js.map +1 -0
  66. package/dist/src/utils/slugify.d.ts +24 -0
  67. package/dist/src/utils/slugify.d.ts.map +1 -0
  68. package/dist/src/utils/slugify.js +56 -0
  69. package/dist/src/utils/slugify.js.map +1 -0
  70. package/docs/architecture.md +349 -0
  71. package/docs/contracts.md +119 -0
  72. package/docs/prompt-template.md +79 -0
  73. package/docs/proposal-close-gates.md +58 -0
  74. package/docs/tool-calling-policy.md +107 -0
  75. package/package.json +53 -0
  76. package/src/contracts/index.ts +12 -0
  77. package/src/contracts/mcp.ts +303 -0
  78. package/src/contracts/schemas.ts +311 -0
  79. package/src/contracts/types.ts +414 -0
  80. package/src/index.ts +8 -0
  81. package/src/server/index.ts +5 -0
  82. package/src/server/mcp-server.ts +352 -0
  83. package/src/services/index.ts +5 -0
  84. package/src/services/memory-service.ts +548 -0
  85. package/src/storage/frontmatter-parser.ts +243 -0
  86. package/src/storage/index.ts +6 -0
  87. package/src/storage/markdown-storage.ts +236 -0
  88. package/src/utils/index.ts +5 -0
  89. package/src/utils/slugify.ts +63 -0
  90. package/test/contracts/schemas.test.ts +313 -0
  91. package/test/contracts/types.test.ts +21 -0
  92. package/test/frontmatter-parser-more.test.ts +94 -0
  93. package/test/server/mcp-server-internals.test.ts +257 -0
  94. package/test/server/mcp-server.test.ts +97 -0
  95. package/test/services/memory-service-edge.test.ts +248 -0
  96. package/test/services/memory-service.test.ts +279 -0
  97. package/test/storage/frontmatter-parser.test.ts +223 -0
  98. package/test/storage/markdown-storage.test.ts +217 -0
  99. package/test/storage/storage-edge.test.ts +238 -0
  100. package/test/utils/slugify-edge.test.ts +94 -0
  101. package/test/utils/slugify.test.ts +68 -0
  102. package/tsconfig.json +26 -0
  103. package/tsconfig.test.json +8 -0
  104. package/vitest.config.ts +27 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * FrontMatter Parser - Handles YAML Front Matter and Markdown content
3
+ */
4
+
5
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
6
+ import type { Memory, MemoryFrontMatter } from '../contracts/types.js';
7
+ import { slugify } from '../utils/slugify.js';
8
+
9
+ /**
10
+ * Result of parsing a markdown file
11
+ */
12
+ export interface ParseResult {
13
+ frontMatter: MemoryFrontMatter;
14
+ title: string;
15
+ content: string;
16
+ }
17
+
18
+ /**
19
+ * Custom error for front matter parsing
20
+ */
21
+ export class FrontMatterError extends Error {
22
+ constructor(
23
+ message: string,
24
+ public readonly cause?: unknown
25
+ ) {
26
+ super(message);
27
+ this.name = 'FrontMatterError';
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Parses a markdown file with YAML front matter
33
+ *
34
+ * Expected format:
35
+ * ---
36
+ * id: "uuid"
37
+ * created_at: "ISO8601"
38
+ * updated_at: "ISO8601"
39
+ * tags: ["tag1", "tag2"]
40
+ * category: "category"
41
+ * importance: 3
42
+ * ---
43
+ *
44
+ * # Title
45
+ *
46
+ * Content...
47
+ *
48
+ * @param markdown - The markdown content to parse
49
+ * @returns Parsed front matter, title, and content
50
+ * @throws FrontMatterError if parsing fails
51
+ */
52
+ export function parseFrontMatter(markdown: string): ParseResult {
53
+ // Check for front matter delimiter
54
+ if (!markdown.startsWith('---')) {
55
+ throw new FrontMatterError('Missing front matter delimiter');
56
+ }
57
+
58
+ // Find the end of front matter
59
+ const endMatch = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
60
+ if (!endMatch) {
61
+ throw new FrontMatterError('Invalid front matter format');
62
+ }
63
+
64
+ const frontMatterYaml = endMatch[1];
65
+ const restOfContent = markdown.slice(endMatch[0].length);
66
+
67
+ // Parse YAML front matter
68
+ let frontMatter: unknown;
69
+ try {
70
+ frontMatter = parseYaml(frontMatterYaml);
71
+ } catch (error) {
72
+ throw new FrontMatterError('Invalid YAML in front matter', error);
73
+ }
74
+
75
+ // Validate required fields
76
+ if (!isValidFrontMatter(frontMatter)) {
77
+ throw new FrontMatterError('Missing required fields in front matter');
78
+ }
79
+
80
+ // Parse title and content from markdown body
81
+ const { title, content } = parseMarkdownBody(restOfContent);
82
+
83
+ return {
84
+ frontMatter,
85
+ title,
86
+ content,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Converts front matter and content to markdown string
92
+ *
93
+ * @param frontMatter - The front matter data
94
+ * @param title - The title (H1 heading)
95
+ * @param content - The markdown content
96
+ * @returns Complete markdown string
97
+ */
98
+ export function stringifyFrontMatter(
99
+ frontMatter: MemoryFrontMatter,
100
+ title: string,
101
+ content: string
102
+ ): string {
103
+ // Convert camelCase to snake_case for YAML
104
+ const yamlData = {
105
+ id: frontMatter.id,
106
+ created_at: frontMatter.created_at,
107
+ updated_at: frontMatter.updated_at,
108
+ ...(frontMatter.session_id ? { session_id: frontMatter.session_id } : {}),
109
+ ...(frontMatter.entry_type ? { entry_type: frontMatter.entry_type } : {}),
110
+ tags: frontMatter.tags.length > 0 ? frontMatter.tags : [],
111
+ category: frontMatter.category,
112
+ importance: frontMatter.importance,
113
+ };
114
+
115
+ // Stringify YAML with specific options for consistent formatting
116
+ const yamlString = stringifyYaml(yamlData, {
117
+ indent: 2,
118
+ defaultKeyType: 'PLAIN',
119
+ defaultStringType: 'QUOTE_DOUBLE',
120
+ });
121
+
122
+ // Build the complete markdown
123
+ const parts: string[] = ['---', yamlString.trim(), '---', ''];
124
+
125
+ // Add title if provided
126
+ if (title) {
127
+ parts.push(`# ${title}`, '');
128
+ }
129
+
130
+ // Add content if provided
131
+ if (content) {
132
+ parts.push(content);
133
+ }
134
+
135
+ // Ensure content ends with a single newline
136
+ let result = parts.join('\n');
137
+ if (!result.endsWith('\n')) {
138
+ result += '\n';
139
+ }
140
+
141
+ return result;
142
+ }
143
+
144
+ /**
145
+ * Parses the markdown body to extract title and content
146
+ *
147
+ * @param body - The markdown body (after front matter)
148
+ * @returns Title and content
149
+ */
150
+ function parseMarkdownBody(body: string): { title: string; content: string } {
151
+ const trimmed = body.trim();
152
+
153
+ if (!trimmed) {
154
+ return { title: '', content: '' };
155
+ }
156
+
157
+ // Try to extract H1 title
158
+ const h1Match = trimmed.match(/^#\s+(.+)$/m);
159
+ if (h1Match) {
160
+ const title = h1Match[1].trim();
161
+ // Remove the H1 line from content
162
+ const content = trimmed.replace(/^#\s+.+$/m, '').trim();
163
+ return { title, content };
164
+ }
165
+
166
+ // No H1 found, treat entire body as content
167
+ return { title: '', content: trimmed };
168
+ }
169
+
170
+ /**
171
+ * Type guard to validate front matter structure
172
+ *
173
+ * @param value - The value to check
174
+ * @returns True if valid front matter
175
+ */
176
+ function isValidFrontMatter(value: unknown): value is MemoryFrontMatter {
177
+ if (typeof value !== 'object' || value === null) {
178
+ return false;
179
+ }
180
+
181
+ const fm = value as Record<string, unknown>;
182
+
183
+ // Check required fields
184
+ if (typeof fm.id !== 'string') return false;
185
+ if (typeof fm.created_at !== 'string') return false;
186
+ if (typeof fm.updated_at !== 'string') return false;
187
+ if (fm.session_id !== undefined && typeof fm.session_id !== 'string') return false;
188
+ if (fm.entry_type !== undefined && typeof fm.entry_type !== 'string') return false;
189
+ if (!Array.isArray(fm.tags)) return false;
190
+ if (typeof fm.category !== 'string') return false;
191
+ if (typeof fm.importance !== 'number') return false;
192
+
193
+ return true;
194
+ }
195
+
196
+ /**
197
+ * Converts a Memory object to front matter format
198
+ *
199
+ * @param memory - The memory object
200
+ * @returns Memory in front matter format
201
+ */
202
+ export function memoryToFrontMatter(memory: Memory): MemoryFrontMatter {
203
+ return {
204
+ id: memory.id,
205
+ created_at: memory.createdAt,
206
+ updated_at: memory.updatedAt,
207
+ session_id: memory.sessionId,
208
+ entry_type: memory.entryType,
209
+ tags: memory.tags,
210
+ category: memory.category,
211
+ importance: memory.importance,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Converts front matter format to Memory object
217
+ *
218
+ * @param frontMatter - The front matter data
219
+ * @param title - The memory title
220
+ * @param content - The memory content
221
+ * @returns Complete Memory object
222
+ */
223
+ export function frontMatterToMemory(
224
+ frontMatter: MemoryFrontMatter,
225
+ title: string,
226
+ content: string
227
+ ): Memory {
228
+ return {
229
+ id: frontMatter.id,
230
+ createdAt: frontMatter.created_at,
231
+ updatedAt: frontMatter.updated_at,
232
+ sessionId: frontMatter.session_id,
233
+ entryType: frontMatter.entry_type,
234
+ tags: frontMatter.tags,
235
+ category: frontMatter.category,
236
+ importance: frontMatter.importance,
237
+ title,
238
+ content,
239
+ };
240
+ }
241
+
242
+ // Re-export slugify for convenience
243
+ export { slugify };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Storage layer exports
3
+ */
4
+
5
+ export * from './frontmatter-parser.js';
6
+ export * from './markdown-storage.js';
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Markdown Storage - Handles file system operations for memory storage
3
+ */
4
+
5
+ import {
6
+ readFile,
7
+ writeFile,
8
+ unlink,
9
+ readdir,
10
+ stat,
11
+ access,
12
+ mkdir,
13
+ } from 'fs/promises';
14
+ import { join, extname } from 'path';
15
+ import { constants } from 'fs';
16
+ import type { Memory, MemoryFile } from '../contracts/types.js';
17
+ import {
18
+ parseFrontMatter,
19
+ stringifyFrontMatter,
20
+ memoryToFrontMatter,
21
+ frontMatterToMemory,
22
+ FrontMatterError,
23
+ } from './frontmatter-parser.js';
24
+ import { slugify } from '../utils/slugify.js';
25
+
26
+ /**
27
+ * Custom error for storage operations
28
+ */
29
+ export class StorageError extends Error {
30
+ constructor(
31
+ message: string,
32
+ public readonly cause?: unknown
33
+ ) {
34
+ super(message);
35
+ this.name = 'StorageError';
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Storage configuration
41
+ */
42
+ export interface StorageConfig {
43
+ /** Root directory for memory storage */
44
+ storagePath: string;
45
+ }
46
+
47
+ /**
48
+ * Markdown file storage implementation
49
+ */
50
+ export class MarkdownStorage {
51
+ private readonly storagePath: string;
52
+
53
+ constructor(config: StorageConfig) {
54
+ this.storagePath = config.storagePath;
55
+ }
56
+
57
+ /**
58
+ * Ensures the storage directory exists
59
+ */
60
+ async initialize(): Promise<void> {
61
+ try {
62
+ await access(this.storagePath, constants.F_OK);
63
+ } catch {
64
+ // Directory doesn't exist, create it
65
+ await mkdir(this.storagePath, { recursive: true });
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Writes a memory to a markdown file
71
+ *
72
+ * @param memory - The memory to write
73
+ * @returns The file path where the memory was stored
74
+ * @throws StorageError if write fails
75
+ */
76
+ async write(memory: Memory): Promise<string> {
77
+ await this.initialize();
78
+
79
+ const { directoryPath, filename } = this.generatePathParts(memory);
80
+ const filePath = join(directoryPath, filename);
81
+ const frontMatter = memoryToFrontMatter(memory);
82
+ const content = stringifyFrontMatter(frontMatter, memory.title, memory.content);
83
+
84
+ try {
85
+ await mkdir(directoryPath, { recursive: true });
86
+ await writeFile(filePath, content, 'utf-8');
87
+ return filePath;
88
+ } catch (error) {
89
+ throw new StorageError(`Failed to write memory file: ${filename}`, error);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Reads a memory by its ID
95
+ *
96
+ * @param id - The memory ID
97
+ * @returns The memory object
98
+ * @throws StorageError if memory not found or read fails
99
+ */
100
+ async read(id: string): Promise<Memory> {
101
+ const filePath = await this.findById(id);
102
+
103
+ if (!filePath) {
104
+ throw new StorageError(`Memory not found: ${id}`);
105
+ }
106
+
107
+ try {
108
+ const content = await readFile(filePath, 'utf-8');
109
+ const { frontMatter, title, content: bodyContent } = parseFrontMatter(content);
110
+ return frontMatterToMemory(frontMatter, title, bodyContent);
111
+ } catch (error) {
112
+ if (error instanceof FrontMatterError) {
113
+ throw new StorageError(`Invalid memory file format: ${filePath}`, error);
114
+ }
115
+ throw new StorageError(`Failed to read memory file: ${filePath}`, error);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Deletes a memory by its ID
121
+ *
122
+ * @param id - The memory ID
123
+ * @returns The file path of the deleted memory
124
+ * @throws StorageError if memory not found or delete fails
125
+ */
126
+ async delete(id: string): Promise<string> {
127
+ const filePath = await this.findById(id);
128
+
129
+ if (!filePath) {
130
+ throw new StorageError(`Memory not found: ${id}`);
131
+ }
132
+
133
+ try {
134
+ await unlink(filePath);
135
+ return filePath;
136
+ } catch (error) {
137
+ throw new StorageError(`Failed to delete memory file: ${filePath}`, error);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Lists all memory files
143
+ *
144
+ * @returns Array of memory file information
145
+ * @throws StorageError if listing fails
146
+ */
147
+ async list(): Promise<MemoryFile[]> {
148
+ await this.initialize();
149
+
150
+ try {
151
+ const markdownPaths = await this.collectMarkdownFiles(this.storagePath);
152
+ const files: MemoryFile[] = [];
153
+
154
+ for (const filePath of markdownPaths) {
155
+ const stats = await stat(filePath);
156
+ const content = await readFile(filePath, 'utf-8');
157
+
158
+ files.push({
159
+ path: filePath,
160
+ filename: filePath.split(/[/\\]/).pop() ?? filePath,
161
+ content,
162
+ modifiedAt: stats.mtime.toISOString(),
163
+ });
164
+ }
165
+
166
+ return files;
167
+ } catch (error) {
168
+ throw new StorageError('Failed to list memory files', error);
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Finds a memory file by ID
174
+ *
175
+ * @param id - The memory ID to find
176
+ * @returns The file path or null if not found
177
+ * @throws StorageError if search fails
178
+ */
179
+ async findById(id: string): Promise<string | null> {
180
+ await this.initialize();
181
+
182
+ try {
183
+ const markdownPaths = await this.collectMarkdownFiles(this.storagePath);
184
+
185
+ for (const filePath of markdownPaths) {
186
+ try {
187
+ const content = await readFile(filePath, 'utf-8');
188
+ const { frontMatter } = parseFrontMatter(content);
189
+ if (frontMatter.id === id) {
190
+ return filePath;
191
+ }
192
+ } catch {
193
+ // Skip files that can't be parsed
194
+ continue;
195
+ }
196
+ }
197
+
198
+ return null;
199
+ } catch (error) {
200
+ throw new StorageError('Failed to search for memory file', error);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Generates nested path parts for a memory
206
+ *
207
+ * Format: {storage}/{YYYY-MM-DD}/{session_uuid}/{timestamp}-{slug}.md
208
+ */
209
+ private generatePathParts(memory: Memory): { directoryPath: string; filename: string } {
210
+ const date = memory.createdAt.split('T')[0];
211
+ const sessionId = memory.sessionId ?? 'default-session';
212
+ const titleSlug = slugify(memory.title) || 'untitled';
213
+ const timestamp = memory.createdAt.replace(/[:.]/g, '-');
214
+ const filename = `${timestamp}-${titleSlug}.md`;
215
+ const directoryPath = join(this.storagePath, date, sessionId);
216
+
217
+ return { directoryPath, filename };
218
+ }
219
+
220
+ private async collectMarkdownFiles(rootDir: string): Promise<string[]> {
221
+ const entries = await readdir(rootDir, { withFileTypes: true });
222
+ const files: string[] = [];
223
+
224
+ for (const entry of entries) {
225
+ const entryPath = join(rootDir, entry.name);
226
+ if (entry.isDirectory()) {
227
+ const nested = await this.collectMarkdownFiles(entryPath);
228
+ files.push(...nested);
229
+ } else if (entry.isFile() && extname(entry.name) === '.md') {
230
+ files.push(entryPath);
231
+ }
232
+ }
233
+
234
+ return files;
235
+ }
236
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Utility exports
3
+ */
4
+
5
+ export * from './slugify.js';
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Slugify utility - Converts strings to URL-friendly slugs
3
+ */
4
+
5
+ /**
6
+ * Converts a string to a URL-friendly slug
7
+ * - Converts to lowercase
8
+ * - Replaces spaces with hyphens
9
+ * - Removes special characters
10
+ * - Collapses multiple hyphens
11
+ * - Trims leading/trailing hyphens
12
+ *
13
+ * @param input - The string to convert
14
+ * @returns The slugified string
15
+ */
16
+ export function slugify(input: string): string {
17
+ if (!input || input.trim().length === 0) {
18
+ return 'untitled';
19
+ }
20
+
21
+ // Convert to lowercase and replace non-alphanumeric characters with hyphens
22
+ const slug = input
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
25
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
26
+ .replace(/-+/g, '-') // Collapse multiple hyphens
27
+ .replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
28
+
29
+ // If empty after cleaning (e.g., only special characters), return 'untitled'
30
+ if (slug.length === 0) {
31
+ return 'untitled';
32
+ }
33
+
34
+ // Truncate to max 100 characters
35
+ if (slug.length > 100) {
36
+ return slug.substring(0, 100).replace(/-+$/, ''); // Don't end with hyphen
37
+ }
38
+
39
+ return slug;
40
+ }
41
+
42
+ /**
43
+ * Generates a unique slug by appending a timestamp or counter if needed
44
+ *
45
+ * @param title - The title to slugify
46
+ * @param existingSlugs - Array of existing slugs to check against
47
+ * @returns A unique slug
48
+ */
49
+ export function generateUniqueSlug(title: string, existingSlugs: readonly string[] = []): string {
50
+ const slug = slugify(title);
51
+ let counter = 1;
52
+ let uniqueSlug = slug;
53
+
54
+ while (existingSlugs.includes(uniqueSlug)) {
55
+ const suffix = `-${counter}`;
56
+ const maxBaseLength = 100 - suffix.length;
57
+ const baseSlug = slug.substring(0, maxBaseLength).replace(/-+$/, '');
58
+ uniqueSlug = `${baseSlug}${suffix}`;
59
+ counter++;
60
+ }
61
+
62
+ return uniqueSlug;
63
+ }