@synth-coder/memhub 0.2.3 → 0.2.4

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 (143) hide show
  1. package/.eslintrc.cjs +45 -45
  2. package/.factory/commands/opsx-apply.md +150 -150
  3. package/.factory/commands/opsx-archive.md +155 -155
  4. package/.factory/commands/opsx-explore.md +171 -171
  5. package/.factory/commands/opsx-propose.md +104 -104
  6. package/.factory/skills/openspec-apply-change/SKILL.md +156 -156
  7. package/.factory/skills/openspec-archive-change/SKILL.md +114 -114
  8. package/.factory/skills/openspec-explore/SKILL.md +288 -288
  9. package/.factory/skills/openspec-propose/SKILL.md +110 -110
  10. package/.github/workflows/ci.yml +110 -74
  11. package/.github/workflows/release.yml +67 -0
  12. package/.iflow/commands/opsx-apply.md +152 -152
  13. package/.iflow/commands/opsx-archive.md +157 -157
  14. package/.iflow/commands/opsx-explore.md +173 -173
  15. package/.iflow/commands/opsx-propose.md +106 -106
  16. package/.iflow/skills/openspec-apply-change/SKILL.md +156 -156
  17. package/.iflow/skills/openspec-archive-change/SKILL.md +114 -114
  18. package/.iflow/skills/openspec-explore/SKILL.md +288 -288
  19. package/.iflow/skills/openspec-propose/SKILL.md +110 -110
  20. package/.prettierrc +11 -11
  21. package/AGENTS.md +167 -169
  22. package/README.md +276 -195
  23. package/README.zh-CN.md +245 -193
  24. package/dist/src/cli/agents/claude-code.d.ts +5 -0
  25. package/dist/src/cli/agents/claude-code.d.ts.map +1 -0
  26. package/dist/src/cli/agents/claude-code.js +14 -0
  27. package/dist/src/cli/agents/claude-code.js.map +1 -0
  28. package/dist/src/cli/agents/cline.d.ts +5 -0
  29. package/dist/src/cli/agents/cline.d.ts.map +1 -0
  30. package/dist/src/cli/agents/cline.js +14 -0
  31. package/dist/src/cli/agents/cline.js.map +1 -0
  32. package/dist/src/cli/agents/codex.d.ts +5 -0
  33. package/dist/src/cli/agents/codex.d.ts.map +1 -0
  34. package/dist/src/cli/agents/codex.js +14 -0
  35. package/dist/src/cli/agents/codex.js.map +1 -0
  36. package/dist/src/cli/agents/cursor.d.ts +5 -0
  37. package/dist/src/cli/agents/cursor.d.ts.map +1 -0
  38. package/dist/src/cli/agents/cursor.js +14 -0
  39. package/dist/src/cli/agents/cursor.js.map +1 -0
  40. package/dist/src/cli/agents/factory-droid.d.ts +5 -0
  41. package/dist/src/cli/agents/factory-droid.d.ts.map +1 -0
  42. package/dist/src/cli/agents/factory-droid.js +14 -0
  43. package/dist/src/cli/agents/factory-droid.js.map +1 -0
  44. package/dist/src/cli/agents/gemini-cli.d.ts +5 -0
  45. package/dist/src/cli/agents/gemini-cli.d.ts.map +1 -0
  46. package/dist/src/cli/agents/gemini-cli.js +14 -0
  47. package/dist/src/cli/agents/gemini-cli.js.map +1 -0
  48. package/dist/src/cli/agents/index.d.ts +14 -0
  49. package/dist/src/cli/agents/index.d.ts.map +1 -0
  50. package/dist/src/cli/agents/index.js +30 -0
  51. package/dist/src/cli/agents/index.js.map +1 -0
  52. package/dist/src/cli/agents/windsurf.d.ts +5 -0
  53. package/dist/src/cli/agents/windsurf.d.ts.map +1 -0
  54. package/dist/src/cli/agents/windsurf.js +14 -0
  55. package/dist/src/cli/agents/windsurf.js.map +1 -0
  56. package/dist/src/cli/index.d.ts +8 -0
  57. package/dist/src/cli/index.d.ts.map +1 -0
  58. package/dist/src/cli/index.js +168 -0
  59. package/dist/src/cli/index.js.map +1 -0
  60. package/dist/src/cli/init.d.ts +34 -0
  61. package/dist/src/cli/init.d.ts.map +1 -0
  62. package/dist/src/cli/init.js +160 -0
  63. package/dist/src/cli/init.js.map +1 -0
  64. package/dist/src/cli/instructions.d.ts +29 -0
  65. package/dist/src/cli/instructions.d.ts.map +1 -0
  66. package/dist/src/cli/instructions.js +141 -0
  67. package/dist/src/cli/instructions.js.map +1 -0
  68. package/dist/src/cli/types.d.ts +22 -0
  69. package/dist/src/cli/types.d.ts.map +1 -0
  70. package/dist/src/cli/types.js +86 -0
  71. package/dist/src/cli/types.js.map +1 -0
  72. package/dist/src/contracts/mcp.js +34 -34
  73. package/dist/src/contracts/schemas.js.map +1 -1
  74. package/dist/src/server/mcp-server.d.ts.map +1 -1
  75. package/dist/src/server/mcp-server.js +7 -14
  76. package/dist/src/server/mcp-server.js.map +1 -1
  77. package/dist/src/services/embedding-service.d.ts.map +1 -1
  78. package/dist/src/services/embedding-service.js +1 -1
  79. package/dist/src/services/embedding-service.js.map +1 -1
  80. package/dist/src/services/memory-service.d.ts.map +1 -1
  81. package/dist/src/services/memory-service.js.map +1 -1
  82. package/dist/src/storage/markdown-storage.d.ts.map +1 -1
  83. package/dist/src/storage/markdown-storage.js +1 -1
  84. package/dist/src/storage/markdown-storage.js.map +1 -1
  85. package/dist/src/storage/vector-index.d.ts.map +1 -1
  86. package/dist/src/storage/vector-index.js +4 -5
  87. package/dist/src/storage/vector-index.js.map +1 -1
  88. package/docs/README.md +21 -0
  89. package/docs/mcp-tools.md +136 -0
  90. package/docs/user-guide.md +182 -0
  91. package/package.json +61 -59
  92. package/src/cli/agents/claude-code.ts +14 -0
  93. package/src/cli/agents/cline.ts +14 -0
  94. package/src/cli/agents/codex.ts +14 -0
  95. package/src/cli/agents/cursor.ts +14 -0
  96. package/src/cli/agents/factory-droid.ts +14 -0
  97. package/src/cli/agents/gemini-cli.ts +14 -0
  98. package/src/cli/agents/index.ts +36 -0
  99. package/src/cli/agents/windsurf.ts +14 -0
  100. package/src/cli/index.ts +192 -0
  101. package/src/cli/init.ts +218 -0
  102. package/src/cli/instructions.ts +156 -0
  103. package/src/cli/types.ts +112 -0
  104. package/src/contracts/index.ts +12 -12
  105. package/src/contracts/mcp.ts +223 -223
  106. package/src/contracts/schemas.ts +307 -307
  107. package/src/contracts/types.ts +410 -410
  108. package/src/index.ts +8 -8
  109. package/src/server/index.ts +5 -5
  110. package/src/server/mcp-server.ts +169 -186
  111. package/src/services/embedding-service.ts +114 -114
  112. package/src/services/index.ts +5 -5
  113. package/src/services/memory-service.ts +656 -663
  114. package/src/storage/frontmatter-parser.ts +243 -243
  115. package/src/storage/index.ts +6 -6
  116. package/src/storage/markdown-storage.ts +228 -236
  117. package/src/storage/vector-index.ts +159 -160
  118. package/src/utils/index.ts +5 -5
  119. package/src/utils/slugify.ts +63 -63
  120. package/test/cli/init.test.ts +380 -0
  121. package/test/contracts/schemas.test.ts +313 -313
  122. package/test/contracts/types.test.ts +21 -21
  123. package/test/frontmatter-parser-more.test.ts +94 -94
  124. package/test/server/mcp-server.test.ts +211 -210
  125. package/test/services/memory-service-edge.test.ts +248 -248
  126. package/test/services/memory-service.test.ts +291 -279
  127. package/test/storage/frontmatter-parser.test.ts +223 -223
  128. package/test/storage/markdown-storage.test.ts +226 -217
  129. package/test/storage/storage-edge.test.ts +238 -238
  130. package/test/storage/vector-index.test.ts +149 -153
  131. package/test/utils/slugify-edge.test.ts +94 -94
  132. package/test/utils/slugify.test.ts +72 -68
  133. package/tsconfig.json +25 -25
  134. package/tsconfig.test.json +8 -8
  135. package/vitest.config.ts +29 -29
  136. package/docs/architecture-diagrams.md +0 -368
  137. package/docs/architecture.md +0 -381
  138. package/docs/contracts.md +0 -190
  139. package/docs/prompt-template.md +0 -33
  140. package/docs/proposals/mcp-typescript-sdk-refactor.md +0 -568
  141. package/docs/proposals/proposal-close-gates.md +0 -58
  142. package/docs/tool-calling-policy.md +0 -101
  143. package/docs/vector-search.md +0 -306
@@ -1,160 +1,159 @@
1
- /**
2
- * VectorIndex - LanceDB-backed vector search index for memories.
3
- *
4
- * This is a search cache only. Markdown files remain the source of truth.
5
- * The index can be rebuilt from Markdown files at any time.
6
- */
7
-
8
- import * as lancedb from '@lancedb/lancedb';
9
- import { mkdir, access } from 'fs/promises';
10
- import { join } from 'path';
11
- import { constants } from 'fs';
12
- import type { Memory } from '../contracts/types.js';
13
-
14
- const TABLE_NAME = 'memories';
15
-
16
- /** Escape single quotes in id strings to prevent SQL injection */
17
- function escapeId(id: string): string {
18
- return id.replace(/'/g, "''");
19
- }
20
-
21
- /**
22
- * Row stored in the LanceDB table.
23
- * The `vector` field is the only one required by LanceDB; all others are metadata filters.
24
- */
25
- export interface VectorRow {
26
- id: string;
27
- vector: number[];
28
- title: string;
29
- category: string;
30
- tags: string; // JSON-serialised string[]
31
- importance: number;
32
- createdAt: string;
33
- updatedAt: string;
34
- }
35
-
36
- export interface VectorSearchResult {
37
- id: string;
38
- /** Cosine distance (lower = more similar). Converted to 0-1 score by caller. */
39
- _distance: number;
40
- }
41
-
42
- /**
43
- * LanceDB vector index wrapper.
44
- * Data lives at `{storagePath}/.lancedb/`.
45
- */
46
- export class VectorIndex {
47
- private readonly dbPath: string;
48
- private db: lancedb.Connection | null = null;
49
- private table: lancedb.Table | null = null;
50
- private initPromise: Promise<void> | null = null;
51
-
52
- constructor(storagePath: string) {
53
- this.dbPath = join(storagePath, '.lancedb');
54
- }
55
-
56
- /** Idempotent initialisation — safe to call multiple times. */
57
- async initialize(): Promise<void> {
58
- if (this.table) return;
59
-
60
- if (!this.initPromise) {
61
- this.initPromise = this._init();
62
- }
63
- await this.initPromise;
64
- }
65
-
66
- private async _init(): Promise<void> {
67
- // Ensure the directory exists
68
- try {
69
- await access(this.dbPath, constants.F_OK);
70
- } catch {
71
- await mkdir(this.dbPath, { recursive: true });
72
- }
73
-
74
- this.db = await lancedb.connect(this.dbPath);
75
-
76
- const existingTables = await this.db.tableNames();
77
- if (existingTables.includes(TABLE_NAME)) {
78
- this.table = await this.db.openTable(TABLE_NAME);
79
- } else {
80
- // Create table with a dummy row so schema is established, then delete it
81
- const dummy: VectorRow = {
82
- id: '__init__',
83
- vector: new Array(384).fill(0) as number[],
84
- title: '',
85
- category: '',
86
- tags: '[]',
87
- importance: 0,
88
- createdAt: '',
89
- updatedAt: '',
90
- };
91
- // LanceDB expects Record<string, unknown>[] but our VectorRow is typed more strictly
92
- // Cast is safe here as VectorRow is a subset of Record<string, unknown>
93
- this.table = await this.db.createTable(TABLE_NAME, [dummy as unknown as Record<string, unknown>]);
94
- await this.table.delete(`id = '__init__'`);
95
- }
96
- }
97
-
98
- /**
99
- * Upserts a memory row into the index.
100
- * LanceDB doesn't have a native upsert so we delete-then-add.
101
- */
102
- async upsert(memory: Memory, vector: number[]): Promise<void> {
103
- await this.initialize();
104
- const table = this.table!;
105
-
106
- // Remove existing row (if any)
107
- await table.delete(`id = '${escapeId(memory.id)}'`);
108
-
109
- const row: VectorRow = {
110
- id: memory.id,
111
- vector,
112
- title: memory.title,
113
- category: memory.category,
114
- tags: JSON.stringify(memory.tags),
115
- importance: memory.importance,
116
- createdAt: memory.createdAt,
117
- updatedAt: memory.updatedAt,
118
- };
119
-
120
- // LanceDB expects Record<string, unknown>[] but our VectorRow is typed more strictly
121
- await table.add([row as unknown as Record<string, unknown>]);
122
- }
123
-
124
- /**
125
- * Removes a memory from the index by ID.
126
- */
127
- async delete(id: string): Promise<void> {
128
- await this.initialize();
129
- await this.table!.delete(`id = '${escapeId(id)}'`);
130
- }
131
-
132
- /**
133
- * Searches for the nearest neighbours to `vector`.
134
- *
135
- * @param vector - Query embedding (must be 384-dim)
136
- * @param limit - Max results to return
137
- * @returns Array ordered by ascending distance (most similar first)
138
- */
139
- async search(vector: number[], limit = 10): Promise<VectorSearchResult[]> {
140
- await this.initialize();
141
-
142
- const results = await this.table!
143
- .vectorSearch(vector)
144
- .limit(limit)
145
- .toArray();
146
-
147
- return results.map((row: Record<string, unknown>) => ({
148
- id: row['id'] as string,
149
- _distance: row['_distance'] as number,
150
- }));
151
- }
152
-
153
- /**
154
- * Returns the number of rows in the index.
155
- */
156
- async count(): Promise<number> {
157
- await this.initialize();
158
- return this.table!.countRows();
159
- }
160
- }
1
+ /**
2
+ * VectorIndex - LanceDB-backed vector search index for memories.
3
+ *
4
+ * This is a search cache only. Markdown files remain the source of truth.
5
+ * The index can be rebuilt from Markdown files at any time.
6
+ */
7
+
8
+ import * as lancedb from '@lancedb/lancedb';
9
+ import { mkdir, access } from 'fs/promises';
10
+ import { join } from 'path';
11
+ import { constants } from 'fs';
12
+ import type { Memory } from '../contracts/types.js';
13
+
14
+ const TABLE_NAME = 'memories';
15
+
16
+ /** Escape single quotes in id strings to prevent SQL injection */
17
+ function escapeId(id: string): string {
18
+ return id.replace(/'/g, "''");
19
+ }
20
+
21
+ /**
22
+ * Row stored in the LanceDB table.
23
+ * The `vector` field is the only one required by LanceDB; all others are metadata filters.
24
+ */
25
+ export interface VectorRow {
26
+ id: string;
27
+ vector: number[];
28
+ title: string;
29
+ category: string;
30
+ tags: string; // JSON-serialised string[]
31
+ importance: number;
32
+ createdAt: string;
33
+ updatedAt: string;
34
+ }
35
+
36
+ export interface VectorSearchResult {
37
+ id: string;
38
+ /** Cosine distance (lower = more similar). Converted to 0-1 score by caller. */
39
+ _distance: number;
40
+ }
41
+
42
+ /**
43
+ * LanceDB vector index wrapper.
44
+ * Data lives at `{storagePath}/.lancedb/`.
45
+ */
46
+ export class VectorIndex {
47
+ private readonly dbPath: string;
48
+ private db: lancedb.Connection | null = null;
49
+ private table: lancedb.Table | null = null;
50
+ private initPromise: Promise<void> | null = null;
51
+
52
+ constructor(storagePath: string) {
53
+ this.dbPath = join(storagePath, '.lancedb');
54
+ }
55
+
56
+ /** Idempotent initialisation — safe to call multiple times. */
57
+ async initialize(): Promise<void> {
58
+ if (this.table) return;
59
+
60
+ if (!this.initPromise) {
61
+ this.initPromise = this._init();
62
+ }
63
+ await this.initPromise;
64
+ }
65
+
66
+ private async _init(): Promise<void> {
67
+ // Ensure the directory exists
68
+ try {
69
+ await access(this.dbPath, constants.F_OK);
70
+ } catch {
71
+ await mkdir(this.dbPath, { recursive: true });
72
+ }
73
+
74
+ this.db = await lancedb.connect(this.dbPath);
75
+
76
+ const existingTables = await this.db.tableNames();
77
+ if (existingTables.includes(TABLE_NAME)) {
78
+ this.table = await this.db.openTable(TABLE_NAME);
79
+ } else {
80
+ // Create table with a dummy row so schema is established, then delete it
81
+ const dummy: VectorRow = {
82
+ id: '__init__',
83
+ vector: new Array(384).fill(0) as number[],
84
+ title: '',
85
+ category: '',
86
+ tags: '[]',
87
+ importance: 0,
88
+ createdAt: '',
89
+ updatedAt: '',
90
+ };
91
+ // LanceDB expects Record<string, unknown>[] but our VectorRow is typed more strictly
92
+ // Cast is safe here as VectorRow is a subset of Record<string, unknown>
93
+ this.table = await this.db.createTable(TABLE_NAME, [
94
+ dummy as unknown as Record<string, unknown>,
95
+ ]);
96
+ await this.table.delete(`id = '__init__'`);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Upserts a memory row into the index.
102
+ * LanceDB doesn't have a native upsert so we delete-then-add.
103
+ */
104
+ async upsert(memory: Memory, vector: number[]): Promise<void> {
105
+ await this.initialize();
106
+ const table = this.table!;
107
+
108
+ // Remove existing row (if any)
109
+ await table.delete(`id = '${escapeId(memory.id)}'`);
110
+
111
+ const row: VectorRow = {
112
+ id: memory.id,
113
+ vector,
114
+ title: memory.title,
115
+ category: memory.category,
116
+ tags: JSON.stringify(memory.tags),
117
+ importance: memory.importance,
118
+ createdAt: memory.createdAt,
119
+ updatedAt: memory.updatedAt,
120
+ };
121
+
122
+ // LanceDB expects Record<string, unknown>[] but our VectorRow is typed more strictly
123
+ await table.add([row as unknown as Record<string, unknown>]);
124
+ }
125
+
126
+ /**
127
+ * Removes a memory from the index by ID.
128
+ */
129
+ async delete(id: string): Promise<void> {
130
+ await this.initialize();
131
+ await this.table!.delete(`id = '${escapeId(id)}'`);
132
+ }
133
+
134
+ /**
135
+ * Searches for the nearest neighbours to `vector`.
136
+ *
137
+ * @param vector - Query embedding (must be 384-dim)
138
+ * @param limit - Max results to return
139
+ * @returns Array ordered by ascending distance (most similar first)
140
+ */
141
+ async search(vector: number[], limit = 10): Promise<VectorSearchResult[]> {
142
+ await this.initialize();
143
+
144
+ const results = await this.table!.vectorSearch(vector).limit(limit).toArray();
145
+
146
+ return results.map((row: Record<string, unknown>) => ({
147
+ id: row['id'] as string,
148
+ _distance: row['_distance'] as number,
149
+ }));
150
+ }
151
+
152
+ /**
153
+ * Returns the number of rows in the index.
154
+ */
155
+ async count(): Promise<number> {
156
+ await this.initialize();
157
+ return this.table!.countRows();
158
+ }
159
+ }
@@ -1,5 +1,5 @@
1
- /**
2
- * Utility exports
3
- */
4
-
5
- export * from './slugify.js';
1
+ /**
2
+ * Utility exports
3
+ */
4
+
5
+ export * from './slugify.js';
@@ -1,63 +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
- }
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
+ }