@critical-path/core 0.9.0 → 0.11.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 (52) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +2 -2
  3. package/dist/domain/events.d.ts +21 -2
  4. package/dist/domain/events.d.ts.map +1 -1
  5. package/dist/domain/events.js.map +1 -1
  6. package/dist/engine/index.d.ts +8 -0
  7. package/dist/engine/index.d.ts.map +1 -1
  8. package/dist/engine/index.js +76 -2
  9. package/dist/engine/index.js.map +1 -1
  10. package/dist/index.test.js +71 -0
  11. package/dist/index.test.js.map +1 -1
  12. package/dist/store/firebase.d.ts +8 -0
  13. package/dist/store/firebase.d.ts.map +1 -1
  14. package/dist/store/firebase.js +22 -0
  15. package/dist/store/firebase.js.map +1 -1
  16. package/dist/store/index.d.ts +16 -0
  17. package/dist/store/index.d.ts.map +1 -1
  18. package/dist/store/index.js +34 -0
  19. package/dist/store/index.js.map +1 -1
  20. package/dist/store/sqlite.d.ts +9 -0
  21. package/dist/store/sqlite.d.ts.map +1 -1
  22. package/dist/store/sqlite.js +59 -12
  23. package/dist/store/sqlite.js.map +1 -1
  24. package/dist/store/sqlite.test.js +14 -1
  25. package/dist/store/sqlite.test.js.map +1 -1
  26. package/dist/types/index.d.ts +8 -1
  27. package/dist/types/index.d.ts.map +1 -1
  28. package/dist/utils/index.d.ts +1 -0
  29. package/dist/utils/index.d.ts.map +1 -1
  30. package/dist/utils/index.js +1 -0
  31. package/dist/utils/index.js.map +1 -1
  32. package/dist/utils/mentions.d.ts +24 -0
  33. package/dist/utils/mentions.d.ts.map +1 -0
  34. package/dist/utils/mentions.js +73 -0
  35. package/dist/utils/mentions.js.map +1 -0
  36. package/dist/utils/mentions.test.d.ts +2 -0
  37. package/dist/utils/mentions.test.d.ts.map +1 -0
  38. package/dist/utils/mentions.test.js +71 -0
  39. package/dist/utils/mentions.test.js.map +1 -0
  40. package/package.json +1 -1
  41. package/src/domain/events.ts +13 -0
  42. package/src/engine/index.ts +85 -2
  43. package/src/index.test.ts +83 -0
  44. package/src/store/firebase.ts +24 -0
  45. package/src/store/index.ts +38 -0
  46. package/src/store/sqlite.test.ts +17 -1
  47. package/src/store/sqlite.ts +64 -10
  48. package/src/types/index.ts +10 -0
  49. package/src/utils/index.ts +1 -0
  50. package/src/utils/mentions.test.ts +82 -0
  51. package/src/utils/mentions.ts +89 -0
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -71,6 +71,8 @@ export interface CommentRepository {
71
71
  addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment>;
72
72
  updateComment(id: string, updates: Partial<Comment>): Promise<Comment | null>;
73
73
  deleteComment(id: string): Promise<boolean>;
74
+ addReaction?(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null>;
75
+ removeReaction?(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null>;
74
76
  }
75
77
 
76
78
  export interface AttachmentRepository {
@@ -369,6 +371,42 @@ export class InMemoryStore implements StorageAdapter {
369
371
  return this.comments.delete(id);
370
372
  }
371
373
 
374
+ async addReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
375
+ const existing = this.comments.get(commentId);
376
+ if (!existing) return null;
377
+ const reactions = existing.reactions ? [...existing.reactions] : [];
378
+ const alreadyExists = reactions.some((r) => r.emoji === reaction.emoji && r.userId === reaction.userId);
379
+ if (!alreadyExists) {
380
+ reactions.push({
381
+ emoji: reaction.emoji,
382
+ userId: reaction.userId,
383
+ createdAt: new Date().toISOString()
384
+ });
385
+ }
386
+ const updated: Comment = {
387
+ ...existing,
388
+ reactions,
389
+ updatedAt: new Date().toISOString()
390
+ };
391
+ this.comments.set(commentId, updated);
392
+ return updated;
393
+ }
394
+
395
+ async removeReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
396
+ const existing = this.comments.get(commentId);
397
+ if (!existing) return null;
398
+ const reactions = (existing.reactions || []).filter(
399
+ (r) => !(r.emoji === reaction.emoji && r.userId === reaction.userId)
400
+ );
401
+ const updated: Comment = {
402
+ ...existing,
403
+ reactions,
404
+ updatedAt: new Date().toISOString()
405
+ };
406
+ this.comments.set(commentId, updated);
407
+ return updated;
408
+ }
409
+
372
410
  // Attachments
373
411
  async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string }): Promise<Attachment[]> {
374
412
  return Array.from(this.attachments.values()).filter((a) => {
@@ -77,11 +77,16 @@ describe('SQLiteStore', () => {
77
77
  const comments = await store.getComments('task_1');
78
78
  expect(comments).toHaveLength(2);
79
79
 
80
- const updatedComment = await store.updateComment(comment.id, { content: 'Updated content' });
80
+ const updatedComment = await store.updateComment(comment.id, {
81
+ content: 'Updated content',
82
+ mentions: ['planner', 'coordinator']
83
+ });
81
84
  expect(updatedComment?.content).toBe('Updated content');
85
+ expect(updatedComment?.mentions).toEqual(['planner', 'coordinator']);
82
86
 
83
87
  const fetchedComment = await store.getComment(comment.id);
84
88
  expect(fetchedComment?.content).toBe('Updated content');
89
+ expect(fetchedComment?.mentions).toEqual(['planner', 'coordinator']);
85
90
 
86
91
  const timeEntry = await store.logTime({
87
92
  taskId: 'task_1',
@@ -94,6 +99,17 @@ describe('SQLiteStore', () => {
94
99
  const entries = await store.getTimeEntries('task_1');
95
100
  expect(entries).toHaveLength(1);
96
101
 
102
+ // Emoji reactions
103
+ const reactComment = await store.addReaction(comment.id, { emoji: '🔥', userId: 'user_2' });
104
+ expect(reactComment?.reactions).toHaveLength(1);
105
+ expect(reactComment?.reactions?.[0].emoji).toBe('🔥');
106
+
107
+ const fetchedWithReactions = await store.getComment(comment.id);
108
+ expect(fetchedWithReactions?.reactions).toHaveLength(1);
109
+
110
+ const unreactComment = await store.removeReaction(comment.id, { emoji: '🔥', userId: 'user_2' });
111
+ expect(unreactComment?.reactions).toHaveLength(0);
112
+
97
113
  const deletedComment = await store.deleteComment(reply.id);
98
114
  expect(deletedComment).toBe(true);
99
115
  expect(await store.getComments('task_1')).toHaveLength(1);
@@ -154,6 +154,8 @@ export class SQLiteStore implements StorageAdapter {
154
154
  authorType TEXT,
155
155
  parentId TEXT,
156
156
  content TEXT NOT NULL,
157
+ mentions TEXT,
158
+ reactions TEXT,
157
159
  metadata TEXT,
158
160
  createdAt TEXT NOT NULL,
159
161
  updatedAt TEXT NOT NULL
@@ -212,6 +214,18 @@ export class SQLiteStore implements StorageAdapter {
212
214
  createdAt TEXT NOT NULL
213
215
  );
214
216
  `);
217
+
218
+ try {
219
+ this.db.exec('ALTER TABLE comments ADD COLUMN reactions TEXT');
220
+ } catch {
221
+ // Column may already exist
222
+ }
223
+
224
+ try {
225
+ this.db.exec('ALTER TABLE comments ADD COLUMN mentions TEXT');
226
+ } catch {
227
+ // Column may already exist
228
+ }
215
229
  }
216
230
 
217
231
  // --- Projects ---
@@ -682,23 +696,33 @@ export class SQLiteStore implements StorageAdapter {
682
696
  }
683
697
 
684
698
  // --- Comments ---
699
+ private mapComment(row: any): Comment {
700
+ return {
701
+ id: row.id,
702
+ taskId: row.taskId,
703
+ authorId: row.authorId,
704
+ authorType: row.authorType,
705
+ parentId: row.parentId || undefined,
706
+ content: row.content,
707
+ mentions: row.mentions ? JSON.parse(row.mentions) : undefined,
708
+ reactions: row.reactions ? JSON.parse(row.reactions) : undefined,
709
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
710
+ createdAt: row.createdAt,
711
+ updatedAt: row.updatedAt
712
+ };
713
+ }
714
+
685
715
  async getComments(taskId: string): Promise<Comment[]> {
686
716
  const stmt = this.db.prepare('SELECT * FROM comments WHERE taskId = ? ORDER BY createdAt ASC');
687
717
  const rows = stmt.all(taskId) as any[];
688
- return rows.map((r) => ({
689
- ...r,
690
- metadata: r.metadata ? JSON.parse(r.metadata) : undefined
691
- }));
718
+ return rows.map((r) => this.mapComment(r));
692
719
  }
693
720
 
694
721
  async getComment(id: string): Promise<Comment | null> {
695
722
  const stmt = this.db.prepare('SELECT * FROM comments WHERE id = ?');
696
723
  const row = stmt.get(id) as any;
697
724
  if (!row) return null;
698
- return {
699
- ...row,
700
- metadata: row.metadata ? JSON.parse(row.metadata) : undefined
701
- };
725
+ return this.mapComment(row);
702
726
  }
703
727
 
704
728
  async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
@@ -707,8 +731,8 @@ export class SQLiteStore implements StorageAdapter {
707
731
  const newComment: Comment = { ...comment, id, createdAt: now, updatedAt: now };
708
732
 
709
733
  const stmt = this.db.prepare(`
710
- INSERT INTO comments (id, taskId, authorId, authorType, parentId, content, metadata, createdAt, updatedAt)
711
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
734
+ INSERT INTO comments (id, taskId, authorId, authorType, parentId, content, mentions, reactions, metadata, createdAt, updatedAt)
735
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
712
736
  `);
713
737
  stmt.run(
714
738
  newComment.id,
@@ -717,6 +741,8 @@ export class SQLiteStore implements StorageAdapter {
717
741
  newComment.authorType || 'user',
718
742
  newComment.parentId || null,
719
743
  newComment.content,
744
+ newComment.mentions ? JSON.stringify(newComment.mentions) : null,
745
+ newComment.reactions ? JSON.stringify(newComment.reactions) : null,
720
746
  newComment.metadata ? JSON.stringify(newComment.metadata) : null,
721
747
  newComment.createdAt,
722
748
  newComment.updatedAt
@@ -740,6 +766,8 @@ export class SQLiteStore implements StorageAdapter {
740
766
  authorType = ?,
741
767
  parentId = ?,
742
768
  content = ?,
769
+ mentions = ?,
770
+ reactions = ?,
743
771
  metadata = ?,
744
772
  updatedAt = ?
745
773
  WHERE id = ?
@@ -749,6 +777,8 @@ export class SQLiteStore implements StorageAdapter {
749
777
  updated.authorType || 'user',
750
778
  updated.parentId || null,
751
779
  updated.content,
780
+ updated.mentions ? JSON.stringify(updated.mentions) : null,
781
+ updated.reactions ? JSON.stringify(updated.reactions) : null,
752
782
  updated.metadata ? JSON.stringify(updated.metadata) : null,
753
783
  updated.updatedAt,
754
784
  id
@@ -762,6 +792,30 @@ export class SQLiteStore implements StorageAdapter {
762
792
  return (result.changes ?? 0) > 0;
763
793
  }
764
794
 
795
+ async addReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
796
+ const existing = await this.getComment(commentId);
797
+ if (!existing) return null;
798
+ const reactions = existing.reactions ? [...existing.reactions] : [];
799
+ const alreadyExists = reactions.some((r) => r.emoji === reaction.emoji && r.userId === reaction.userId);
800
+ if (!alreadyExists) {
801
+ reactions.push({
802
+ emoji: reaction.emoji,
803
+ userId: reaction.userId,
804
+ createdAt: new Date().toISOString()
805
+ });
806
+ }
807
+ return this.updateComment(commentId, { reactions });
808
+ }
809
+
810
+ async removeReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
811
+ const existing = await this.getComment(commentId);
812
+ if (!existing) return null;
813
+ const reactions = (existing.reactions || []).filter(
814
+ (r) => !(r.emoji === reaction.emoji && r.userId === reaction.userId)
815
+ );
816
+ return this.updateComment(commentId, { reactions });
817
+ }
818
+
765
819
  // --- Attachments ---
766
820
  async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string }): Promise<Attachment[]> {
767
821
  let sql = 'SELECT * FROM attachments';
@@ -185,6 +185,12 @@ export interface Iteration {
185
185
 
186
186
  export type AuthorType = 'user' | 'agent' | 'system';
187
187
 
188
+ export interface CommentReaction {
189
+ emoji: string;
190
+ userId: string;
191
+ createdAt?: string;
192
+ }
193
+
188
194
  export interface Comment {
189
195
  id: string;
190
196
  taskId: string;
@@ -192,6 +198,8 @@ export interface Comment {
192
198
  authorType?: AuthorType;
193
199
  parentId?: string; // Threaded reply support
194
200
  content: string;
201
+ mentions?: string[];
202
+ reactions?: CommentReaction[];
195
203
  metadata?: Record<string, unknown>;
196
204
  createdAt: string;
197
205
  updatedAt: string;
@@ -291,6 +299,8 @@ export type WebhookEvent =
291
299
  | 'comment.created'
292
300
  | 'comment.updated'
293
301
  | 'comment.deleted'
302
+ | 'comment.reaction.added'
303
+ | 'comment.reaction.removed'
294
304
  | 'attachment.created'
295
305
  | 'attachment.deleted'
296
306
  | 'iteration.started'
@@ -1,4 +1,5 @@
1
1
  export * from './status.js';
2
2
  export * from './key.js';
3
3
  export * from './workflow.js';
4
+ export * from './mentions.js';
4
5
 
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractMentions, parseMentionSegments } from './mentions.js';
3
+
4
+ describe('mentions utility', () => {
5
+ describe('extractMentions', () => {
6
+ it('returns empty array for empty or non-string input', () => {
7
+ expect(extractMentions('')).toEqual([]);
8
+ expect(extractMentions(null as any)).toEqual([]);
9
+ expect(extractMentions(undefined as any)).toEqual([]);
10
+ });
11
+
12
+ it('extracts single mention', () => {
13
+ expect(extractMentions('Hello @planner, please review.')).toEqual(['planner']);
14
+ });
15
+
16
+ it('extracts multiple mentions and deduplicates them', () => {
17
+ const text = 'Hey @jane and @planner, can @jane check with @coordinator?';
18
+ expect(extractMentions(text)).toEqual(['jane', 'planner', 'coordinator']);
19
+ });
20
+
21
+ it('supports quoted handles with spaces', () => {
22
+ const text = 'Assigning to @"Agent Planner" and @"Jane Doe"';
23
+ expect(extractMentions(text)).toEqual(['Agent Planner', 'Jane Doe']);
24
+ });
25
+
26
+ it('ignores email addresses without leading space or boundary', () => {
27
+ const text = 'Send mail to user@example.com or ping @alex';
28
+ expect(extractMentions(text)).toEqual(['alex']);
29
+ });
30
+
31
+ it('handles mentions with dashes, underscores, and periods', () => {
32
+ const text = 'Call @agent-1, @super_user, and @john.doe';
33
+ expect(extractMentions(text)).toEqual(['agent-1', 'super_user', 'john.doe']);
34
+ });
35
+ });
36
+
37
+ describe('parseMentionSegments', () => {
38
+ it('returns empty array for empty string', () => {
39
+ expect(parseMentionSegments('')).toEqual([]);
40
+ });
41
+
42
+ it('returns single text segment if no mentions', () => {
43
+ expect(parseMentionSegments('Just a normal comment without tags.')).toEqual([
44
+ { type: 'text', value: 'Just a normal comment without tags.' }
45
+ ]);
46
+ });
47
+
48
+ it('correctly segments text with single mention in the middle', () => {
49
+ const result = parseMentionSegments('Hello @planner, please check.');
50
+ expect(result).toEqual([
51
+ { type: 'text', value: 'Hello ' },
52
+ { type: 'mention', value: '@planner', handle: 'planner' },
53
+ { type: 'text', value: ', please check.' }
54
+ ]);
55
+ });
56
+
57
+ it('correctly segments text starting with a mention', () => {
58
+ const result = parseMentionSegments('@planner can you take a look?');
59
+ expect(result).toEqual([
60
+ { type: 'mention', value: '@planner', handle: 'planner' },
61
+ { type: 'text', value: ' can you take a look?' }
62
+ ]);
63
+ });
64
+
65
+ it('correctly segments text ending with a mention', () => {
66
+ const result = parseMentionSegments('Assigned to @coordinator');
67
+ expect(result).toEqual([
68
+ { type: 'text', value: 'Assigned to ' },
69
+ { type: 'mention', value: '@coordinator', handle: 'coordinator' }
70
+ ]);
71
+ });
72
+
73
+ it('correctly segments quoted mentions', () => {
74
+ const result = parseMentionSegments('Ping @"Agent Supervisor" ASAP');
75
+ expect(result).toEqual([
76
+ { type: 'text', value: 'Ping ' },
77
+ { type: 'mention', value: '@"Agent Supervisor"', handle: 'Agent Supervisor' },
78
+ { type: 'text', value: ' ASAP' }
79
+ ]);
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,89 @@
1
+ export interface MentionSegment {
2
+ type: 'text' | 'mention';
3
+ value: string;
4
+ handle?: string;
5
+ }
6
+
7
+ /**
8
+ * Regex matching mentions:
9
+ * - Quoted mentions: @"Jane Doe"
10
+ * - Standard mentions: @jane_doe, @planner, @user-1, @john.smith
11
+ * Note: Does not match emails like foo@bar.com (requires word boundary or start of string before @).
12
+ */
13
+ export const MENTION_REGEX = /(?:^|\s)@(?:"([^"]+)"|([a-zA-Z0-9_.-]+))/g;
14
+
15
+ /**
16
+ * Extracts unique mention handles from a text string.
17
+ * Strips quotes and the leading '@' symbol.
18
+ * Preserves the order of their first appearance.
19
+ */
20
+ export function extractMentions(content: string): string[] {
21
+ if (!content || typeof content !== 'string') return [];
22
+
23
+ const mentions: string[] = [];
24
+ const seen = new Set<string>();
25
+
26
+ // Reset regex lastIndex
27
+ const regex = new RegExp(MENTION_REGEX.source, 'g');
28
+ let match: RegExpExecArray | null;
29
+
30
+ while ((match = regex.exec(content)) !== null) {
31
+ const handle = match[1] ?? match[2];
32
+ if (handle && !seen.has(handle)) {
33
+ seen.add(handle);
34
+ mentions.push(handle);
35
+ }
36
+ }
37
+
38
+ return mentions;
39
+ }
40
+
41
+ /**
42
+ * Segments a text string into plain text parts and mention tokens.
43
+ * Useful for rendering highlighted mention badges or links in UI components.
44
+ */
45
+ export function parseMentionSegments(content: string): MentionSegment[] {
46
+ if (!content) return [];
47
+
48
+ const segments: MentionSegment[] = [];
49
+ const regex = new RegExp(MENTION_REGEX.source, 'g');
50
+ let lastIndex = 0;
51
+ let match: RegExpExecArray | null;
52
+
53
+ while ((match = regex.exec(content)) !== null) {
54
+ const fullMatch = match[0];
55
+ const leadingWhitespace = fullMatch.startsWith(' ') || fullMatch.startsWith('\t') || fullMatch.startsWith('\n')
56
+ ? fullMatch[0]
57
+ : '';
58
+ const mentionToken = fullMatch.slice(leadingWhitespace.length);
59
+ const handle = match[1] ?? match[2];
60
+ const matchStart = match.index + leadingWhitespace.length;
61
+
62
+ // Push text before this mention if any
63
+ if (matchStart > lastIndex) {
64
+ segments.push({
65
+ type: 'text',
66
+ value: content.slice(lastIndex, matchStart)
67
+ });
68
+ }
69
+
70
+ // Push the mention token
71
+ segments.push({
72
+ type: 'mention',
73
+ value: mentionToken,
74
+ handle
75
+ });
76
+
77
+ lastIndex = match.index + fullMatch.length;
78
+ }
79
+
80
+ // Push any remaining text after the last match
81
+ if (lastIndex < content.length) {
82
+ segments.push({
83
+ type: 'text',
84
+ value: content.slice(lastIndex)
85
+ });
86
+ }
87
+
88
+ return segments;
89
+ }
@@ -1 +1 @@
1
- {"root":["./src/index.test.ts","./src/index.ts","./src/use-case-validation.test.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}
1
+ {"root":["./src/index.test.ts","./src/index.ts","./src/use-case-validation.test.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}