@memoire-ai/collector 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 (97) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-test.log +45 -0
  3. package/cursor-hooks/README.md +119 -0
  4. package/cursor-hooks/context-inject.sh +118 -0
  5. package/cursor-hooks/hooks.json +39 -0
  6. package/cursor-hooks/save-file-edit.sh +130 -0
  7. package/cursor-hooks/save-observation.sh +116 -0
  8. package/cursor-hooks/save-shell-execution.sh +121 -0
  9. package/cursor-hooks/session-summary.sh +142 -0
  10. package/dist/capture.d.ts +111 -0
  11. package/dist/capture.d.ts.map +1 -0
  12. package/dist/capture.integration.d.ts +2 -0
  13. package/dist/capture.integration.d.ts.map +1 -0
  14. package/dist/capture.integration.js +67 -0
  15. package/dist/capture.integration.js.map +1 -0
  16. package/dist/capture.js +264 -0
  17. package/dist/capture.js.map +1 -0
  18. package/dist/client-summarizer.d.ts +59 -0
  19. package/dist/client-summarizer.d.ts.map +1 -0
  20. package/dist/client-summarizer.js +211 -0
  21. package/dist/client-summarizer.js.map +1 -0
  22. package/dist/client-summarizer.test.d.ts +2 -0
  23. package/dist/client-summarizer.test.d.ts.map +1 -0
  24. package/dist/client-summarizer.test.js +127 -0
  25. package/dist/client-summarizer.test.js.map +1 -0
  26. package/dist/config.d.ts +13 -0
  27. package/dist/config.d.ts.map +1 -0
  28. package/dist/config.js +131 -0
  29. package/dist/config.js.map +1 -0
  30. package/dist/config.test.d.ts +2 -0
  31. package/dist/config.test.d.ts.map +1 -0
  32. package/dist/config.test.js +182 -0
  33. package/dist/config.test.js.map +1 -0
  34. package/dist/cursor-hooks.d.ts +46 -0
  35. package/dist/cursor-hooks.d.ts.map +1 -0
  36. package/dist/cursor-hooks.js +251 -0
  37. package/dist/cursor-hooks.js.map +1 -0
  38. package/dist/cursor-rules.d.ts +42 -0
  39. package/dist/cursor-rules.d.ts.map +1 -0
  40. package/dist/cursor-rules.js +229 -0
  41. package/dist/cursor-rules.js.map +1 -0
  42. package/dist/cursor-rules.test.d.ts +2 -0
  43. package/dist/cursor-rules.test.d.ts.map +1 -0
  44. package/dist/cursor-rules.test.js +55 -0
  45. package/dist/cursor-rules.test.js.map +1 -0
  46. package/dist/dedup.d.ts +22 -0
  47. package/dist/dedup.d.ts.map +1 -0
  48. package/dist/dedup.js +60 -0
  49. package/dist/dedup.js.map +1 -0
  50. package/dist/dedup.test.d.ts +2 -0
  51. package/dist/dedup.test.d.ts.map +1 -0
  52. package/dist/dedup.test.js +83 -0
  53. package/dist/dedup.test.js.map +1 -0
  54. package/dist/hooks/index.d.ts +52 -0
  55. package/dist/hooks/index.d.ts.map +1 -0
  56. package/dist/hooks/index.js +136 -0
  57. package/dist/hooks/index.js.map +1 -0
  58. package/dist/hooks.test.d.ts +2 -0
  59. package/dist/hooks.test.d.ts.map +1 -0
  60. package/dist/hooks.test.js +94 -0
  61. package/dist/hooks.test.js.map +1 -0
  62. package/dist/index.d.ts +9 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +9 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/strip-private.d.ts +12 -0
  67. package/dist/strip-private.d.ts.map +1 -0
  68. package/dist/strip-private.js +28 -0
  69. package/dist/strip-private.js.map +1 -0
  70. package/dist/strip-private.test.d.ts +2 -0
  71. package/dist/strip-private.test.d.ts.map +1 -0
  72. package/dist/strip-private.test.js +37 -0
  73. package/dist/strip-private.test.js.map +1 -0
  74. package/dist/utils.d.ts +3 -0
  75. package/dist/utils.d.ts.map +1 -0
  76. package/dist/utils.js +11 -0
  77. package/dist/utils.js.map +1 -0
  78. package/package.json +28 -0
  79. package/src/capture.integration.ts +98 -0
  80. package/src/capture.ts +352 -0
  81. package/src/client-summarizer.test.ts +144 -0
  82. package/src/client-summarizer.ts +338 -0
  83. package/src/config.test.ts +211 -0
  84. package/src/config.ts +157 -0
  85. package/src/cursor-hooks.ts +309 -0
  86. package/src/cursor-rules.test.ts +63 -0
  87. package/src/cursor-rules.ts +313 -0
  88. package/src/dedup.test.ts +84 -0
  89. package/src/dedup.ts +67 -0
  90. package/src/hooks/index.ts +226 -0
  91. package/src/hooks.test.ts +111 -0
  92. package/src/index.ts +32 -0
  93. package/src/strip-private.test.ts +57 -0
  94. package/src/strip-private.ts +34 -0
  95. package/src/utils.ts +10 -0
  96. package/tsconfig.json +12 -0
  97. package/tsconfig.tsbuildinfo +1 -0
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@memoire-ai/collector",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "dependencies": {
14
+ "@memoire-ai/sdk": "0.1.0",
15
+ "@memoire-ai/shared": "0.1.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^20.0.0",
19
+ "postgres": "^3.4.5",
20
+ "typescript": "^5.7.0"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "dev": "tsc --watch",
25
+ "test": "node --test dist/*.test.js",
26
+ "test:integration": "node --test dist/*.integration.js"
27
+ }
28
+ }
@@ -0,0 +1,98 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import postgres, { type Sql } from 'postgres';
4
+ import { Collector } from './capture.js';
5
+
6
+ const integrationEnv = {
7
+ apiUrl: process.env.MEMOIRE_INTEGRATION_API_URL,
8
+ apiKey: process.env.MEMOIRE_INTEGRATION_API_KEY,
9
+ orgId: process.env.MEMOIRE_INTEGRATION_ORG_ID,
10
+ projectId: process.env.MEMOIRE_INTEGRATION_PROJECT_ID,
11
+ userId: process.env.MEMOIRE_INTEGRATION_USER_ID,
12
+ databaseUrl: process.env.MEMOIRE_INTEGRATION_DATABASE_URL,
13
+ };
14
+
15
+ const missingEnv = Object.entries(integrationEnv)
16
+ .filter(([, value]) => !value)
17
+ .map(([key]) => key);
18
+
19
+ test(
20
+ 'collector forwards captured events to the API and persists them',
21
+ { skip: missingEnv.length > 0 ? `missing env: ${missingEnv.join(', ')}` : false },
22
+ async () => {
23
+ const collector = new Collector({
24
+ apiUrl: integrationEnv.apiUrl!,
25
+ apiKey: integrationEnv.apiKey!,
26
+ orgId: integrationEnv.orgId!,
27
+ projectId: integrationEnv.projectId!,
28
+ userId: integrationEnv.userId!,
29
+ client: 'cursor',
30
+ repoId: 'memoire/integration',
31
+ });
32
+ const sql = postgres(integrationEnv.databaseUrl!);
33
+ const content = `collector integration ${Date.now()}`;
34
+
35
+ try {
36
+ await collector.observation(content, {
37
+ branchName: 'integration/collector',
38
+ filesModified: ['src/auth/config.ts'],
39
+ concepts: ['integration-test'],
40
+ });
41
+
42
+ const row = await waitForEvent(sql, {
43
+ content,
44
+ projectId: integrationEnv.projectId!,
45
+ });
46
+
47
+ assert.ok(row, 'expected collector event to be persisted');
48
+ assert.equal(row.event_type, 'observation');
49
+ assert.equal(row.client, 'cursor');
50
+ assert.equal(row.project_id, integrationEnv.projectId);
51
+ assert.match(row.files_modified, /src\/auth\/config\.ts/);
52
+
53
+ await sql`delete from events where id = ${row.id}`;
54
+ } finally {
55
+ collector.destroy();
56
+ await sql.end({ timeout: 5 });
57
+ }
58
+ }
59
+ );
60
+
61
+ async function waitForEvent(
62
+ sql: Sql,
63
+ input: { content: string; projectId: string }
64
+ ): Promise<
65
+ | {
66
+ id: string;
67
+ project_id: string;
68
+ event_type: string;
69
+ client: string;
70
+ files_modified: string;
71
+ }
72
+ | undefined
73
+ > {
74
+ for (let attempt = 0; attempt < 20; attempt++) {
75
+ const rows = await sql<{
76
+ id: string;
77
+ project_id: string;
78
+ event_type: string;
79
+ client: string;
80
+ files_modified: string;
81
+ }[]>`
82
+ select id, project_id, event_type, client, files_modified::text as files_modified
83
+ from events
84
+ where project_id = ${input.projectId}
85
+ and content = ${input.content}
86
+ order by inserted_at desc
87
+ limit 1
88
+ `;
89
+
90
+ if (rows[0]) {
91
+ return rows[0];
92
+ }
93
+
94
+ await new Promise((resolve) => setTimeout(resolve, 100));
95
+ }
96
+
97
+ return undefined;
98
+ }
package/src/capture.ts ADDED
@@ -0,0 +1,352 @@
1
+ import { MemoireClient, OfflineQueue, type MemoireClientOptions } from '@memoire-ai/sdk';
2
+ import type { MemoireEvent, MemoireClient as ClientType } from '@memoire-ai/shared';
3
+ import { nanoid } from './utils.js';
4
+ import { writeCursorRules, type CursorRulesContext } from './cursor-rules.js';
5
+ import { stripPrivateTags } from './strip-private.js';
6
+ import { ContentDedup } from './dedup.js';
7
+ import { ClientSummarizer, type ClientSummarizerConfig } from './client-summarizer.js';
8
+
9
+ export interface CollectorConfig {
10
+ apiUrl: string;
11
+ apiKey: string;
12
+ orgId: string;
13
+ projectId: string;
14
+ userId: string;
15
+ client: ClientType;
16
+ repoId?: string;
17
+ clientId?: string;
18
+ /** Workspace root path — if set, auto-writes .cursor/rules/ context */
19
+ workspacePath?: string;
20
+ /** Project name for cursor rules */
21
+ projectName?: string;
22
+ /** Dedup window in ms (default 30000). Set to 0 to disable. */
23
+ dedupWindowMs?: number;
24
+ /** Edge/native summarization config. When set, events are pre-summarized
25
+ * before reaching the API, saving server-side summarization cost. */
26
+ summarizer?: ClientSummarizerConfig;
27
+ }
28
+
29
+ /** Structured session summary matching claude-mem's summary schema */
30
+ export interface SessionSummary {
31
+ /** What the user requested */
32
+ request: string;
33
+ /** What was explored / investigated */
34
+ investigated?: string[];
35
+ /** Key learnings and discoveries */
36
+ learned?: string[];
37
+ /** What was delivered / completed */
38
+ completed?: string[];
39
+ /** Suggested follow-up work */
40
+ next_steps?: string[];
41
+ /** Additional notes */
42
+ notes?: string;
43
+ }
44
+
45
+ /**
46
+ * Collector captures events from IDE environments and forwards them
47
+ * to the Memoire API. If the API is unavailable, events are queued
48
+ * locally and retried.
49
+ *
50
+ * Follows the claude-mem hook lifecycle pattern:
51
+ * SessionStart → PostToolUse → SessionEnd
52
+ *
53
+ * Edge processing:
54
+ * - Privacy: <private> tags are stripped before storage
55
+ * - Dedup: Content-hash deduplication within a 30s sliding window
56
+ * - Structured summaries: Session end accepts typed summary objects
57
+ */
58
+ export class Collector {
59
+ private memoireClient: MemoireClient;
60
+ private queue: OfflineQueue;
61
+ private config: CollectorConfig;
62
+ private sessionId: string;
63
+ private dedup: ContentDedup;
64
+ private summarizer: ClientSummarizer | null;
65
+
66
+ constructor(config: CollectorConfig) {
67
+ this.config = config;
68
+ this.sessionId = nanoid();
69
+ this.memoireClient = new MemoireClient({
70
+ apiUrl: config.apiUrl,
71
+ apiKey: config.apiKey,
72
+ clientId: config.clientId,
73
+ });
74
+ this.queue = new OfflineQueue(this.memoireClient);
75
+ this.dedup = new ContentDedup(config.dedupWindowMs ?? 30_000);
76
+ this.summarizer = config.summarizer ? new ClientSummarizer(config.summarizer) : null;
77
+ }
78
+
79
+ /** Start a new session */
80
+ async sessionStart(): Promise<void> {
81
+ this.sessionId = nanoid();
82
+ await this.emit({
83
+ event_type: 'observation',
84
+ content: `Session started on ${this.config.client}`,
85
+ });
86
+ }
87
+
88
+ /** Record an observation from a tool use */
89
+ async observation(content: string, opts?: {
90
+ filesRead?: string[];
91
+ filesModified?: string[];
92
+ concepts?: string[];
93
+ branchName?: string;
94
+ }): Promise<void> {
95
+ await this.emit({
96
+ event_type: 'observation',
97
+ content,
98
+ files_read: opts?.filesRead,
99
+ files_modified: opts?.filesModified,
100
+ concepts: opts?.concepts,
101
+ branch_name: opts?.branchName,
102
+ });
103
+ }
104
+
105
+ /** Record a user prompt */
106
+ async prompt(content: string, opts?: {
107
+ filesRead?: string[];
108
+ filesModified?: string[];
109
+ concepts?: string[];
110
+ branchName?: string;
111
+ }): Promise<void> {
112
+ await this.emit({
113
+ event_type: 'prompt',
114
+ content,
115
+ files_read: opts?.filesRead,
116
+ files_modified: opts?.filesModified,
117
+ concepts: opts?.concepts,
118
+ branch_name: opts?.branchName,
119
+ });
120
+ }
121
+
122
+ /** Record a decision */
123
+ async decision(content: string, opts?: {
124
+ filesModified?: string[];
125
+ concepts?: string[];
126
+ branchName?: string;
127
+ }): Promise<void> {
128
+ await this.emit({
129
+ event_type: 'decision',
130
+ content,
131
+ files_modified: opts?.filesModified,
132
+ concepts: opts?.concepts,
133
+ branch_name: opts?.branchName,
134
+ });
135
+ }
136
+
137
+ /** Record a failed attempt */
138
+ async attempt(content: string, opts?: {
139
+ filesModified?: string[];
140
+ concepts?: string[];
141
+ branchName?: string;
142
+ }): Promise<void> {
143
+ await this.emit({
144
+ event_type: 'attempt',
145
+ content,
146
+ files_modified: opts?.filesModified,
147
+ concepts: opts?.concepts,
148
+ branch_name: opts?.branchName,
149
+ });
150
+ }
151
+
152
+ /** Record a convention or standard */
153
+ async convention(content: string, opts?: {
154
+ filesModified?: string[];
155
+ concepts?: string[];
156
+ branchName?: string;
157
+ }): Promise<void> {
158
+ await this.emit({
159
+ event_type: 'convention',
160
+ content,
161
+ files_modified: opts?.filesModified,
162
+ concepts: opts?.concepts,
163
+ branch_name: opts?.branchName,
164
+ });
165
+ }
166
+
167
+ /** Record a branch lifecycle event */
168
+ async branchEvent(content: string, opts?: {
169
+ filesModified?: string[];
170
+ concepts?: string[];
171
+ branchName?: string;
172
+ }): Promise<void> {
173
+ await this.emit({
174
+ event_type: 'branch_event',
175
+ content,
176
+ files_modified: opts?.filesModified,
177
+ concepts: opts?.concepts,
178
+ branch_name: opts?.branchName,
179
+ });
180
+ }
181
+
182
+ /** End a session with a structured or plain-text summary */
183
+ async sessionEnd(summary: string | SessionSummary): Promise<void> {
184
+ const content = typeof summary === 'string'
185
+ ? summary
186
+ : formatStructuredSummary(summary);
187
+
188
+ await this.emit({
189
+ event_type: 'session_summary',
190
+ content,
191
+ });
192
+ // Flush any remaining events
193
+ await this.queue.flush();
194
+
195
+ // Auto-write Cursor rules context if workspace path is configured
196
+ if (this.config.workspacePath && this.config.client === 'cursor') {
197
+ try {
198
+ await this.updateCursorRules(content);
199
+ } catch {
200
+ // Non-fatal: cursor rules update is best-effort
201
+ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Update .cursor/rules/memoire-context.mdc with latest project context.
207
+ * Called automatically on session end for Cursor clients.
208
+ */
209
+ private async updateCursorRules(sessionSummary: string): Promise<void> {
210
+ if (!this.config.workspacePath) return;
211
+
212
+ // Try to fetch profile from API
213
+ let profile: CursorRulesContext['profile'];
214
+
215
+ try {
216
+ const profileResp = await this.memoireClient.projectProfile({
217
+ org_id: this.config.orgId,
218
+ project_id: this.config.projectId,
219
+ viewer_user_id: this.config.userId,
220
+ });
221
+ if (profileResp?.project_profile) {
222
+ const pp = profileResp.project_profile;
223
+ profile = {
224
+ architecture: pp.architecture?.join(', '),
225
+ stack: pp.key_files?.slice(0, 5),
226
+ conventions: pp.conventions?.slice(0, 10),
227
+ currentFocus: pp.current_focus?.join(', '),
228
+ };
229
+ }
230
+ } catch {
231
+ // API unavailable — write rules with just the session summary
232
+ }
233
+
234
+ writeCursorRules(this.config.workspacePath, {
235
+ projectName: this.config.projectName ?? 'Project',
236
+ profile,
237
+ recentSummary: sessionSummary,
238
+ apiUrl: this.config.apiUrl,
239
+ });
240
+ }
241
+
242
+ /** Emit a raw event — applies privacy stripping, dedup, and optional client-side summarization */
243
+ private async emit(partial: Partial<MemoireEvent> & { event_type: string; content: string }): Promise<void> {
244
+ // Edge processing: strip <private> tags from content AND all metadata fields
245
+ const cleanContent = stripPrivateTags(partial.content);
246
+
247
+ // Skip empty content after stripping
248
+ if (!cleanContent) return;
249
+
250
+ const cleanFilesRead = stripPrivateArray(partial.files_read);
251
+ const cleanFilesModified = stripPrivateArray(partial.files_modified);
252
+ const cleanConcepts = stripPrivateArray(partial.concepts);
253
+ const cleanBranch = partial.branch_name ? stripPrivateTags(partial.branch_name) : partial.branch_name;
254
+
255
+ // Edge processing: deduplicate within sliding window (keyed on event_type + content)
256
+ if (this.dedup.isDuplicate(`${partial.event_type}\0${cleanContent}`)) return;
257
+
258
+ const event: MemoireEvent = {
259
+ org_id: this.config.orgId,
260
+ project_id: this.config.projectId,
261
+ repo_id: this.config.repoId,
262
+ user_id: this.config.userId,
263
+ session_id: this.sessionId,
264
+ client: this.config.client,
265
+ created_at: new Date().toISOString(),
266
+ idempotency_key: nanoid(),
267
+ private: false,
268
+ ...partial,
269
+ event_type: partial.event_type,
270
+ content: cleanContent,
271
+ files_read: cleanFilesRead,
272
+ files_modified: cleanFilesModified,
273
+ concepts: cleanConcepts,
274
+ branch_name: cleanBranch,
275
+ };
276
+
277
+ // Edge processing: client-side AI summarization (non-blocking on failure)
278
+ if (this.summarizer) {
279
+ try {
280
+ const result = await this.summarizer.summarize(
281
+ cleanContent,
282
+ partial.event_type,
283
+ {
284
+ filesRead: cleanFilesRead,
285
+ filesModified: cleanFilesModified,
286
+ concepts: cleanConcepts,
287
+ branchName: cleanBranch,
288
+ },
289
+ );
290
+ if (result) {
291
+ event.summary = result.summary;
292
+ event.summary_details = result.summary_details;
293
+ // Merge AI-extracted concepts with existing ones
294
+ if (result.concepts.length > 0) {
295
+ const merged = new Set([...(event.concepts ?? []), ...result.concepts]);
296
+ event.concepts = Array.from(merged).slice(0, 12);
297
+ }
298
+ }
299
+ } catch {
300
+ // Summarization failure is non-fatal — server will handle it
301
+ }
302
+ }
303
+
304
+ try {
305
+ await this.memoireClient.ingestEvent(event);
306
+ } catch {
307
+ // API unreachable — queue for retry
308
+ this.queue.enqueue([event]);
309
+ }
310
+ }
311
+
312
+ /** Get the number of pending events in the offline queue */
313
+ get pendingEvents(): number {
314
+ return this.queue.pending;
315
+ }
316
+
317
+ destroy(): void {
318
+ this.dedup.destroy();
319
+ this.queue.destroy();
320
+ }
321
+ }
322
+
323
+ /** Strip private tags from each element of a string array */
324
+ function stripPrivateArray(arr?: string[]): string[] | undefined {
325
+ if (!arr) return arr;
326
+ return arr.map(stripPrivateTags).filter(Boolean);
327
+ }
328
+
329
+ /** Format a structured summary into a readable content string */
330
+ function formatStructuredSummary(summary: SessionSummary): string {
331
+ const parts: string[] = [];
332
+
333
+ parts.push(`Request: ${summary.request}`);
334
+
335
+ if (summary.investigated?.length) {
336
+ parts.push(`Investigated: ${summary.investigated.join('; ')}`);
337
+ }
338
+ if (summary.learned?.length) {
339
+ parts.push(`Learned: ${summary.learned.join('; ')}`);
340
+ }
341
+ if (summary.completed?.length) {
342
+ parts.push(`Completed: ${summary.completed.join('; ')}`);
343
+ }
344
+ if (summary.next_steps?.length) {
345
+ parts.push(`Next steps: ${summary.next_steps.join('; ')}`);
346
+ }
347
+ if (summary.notes) {
348
+ parts.push(`Notes: ${summary.notes}`);
349
+ }
350
+
351
+ return parts.join('\n');
352
+ }
@@ -0,0 +1,144 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { ClientSummarizer } from './client-summarizer.js';
4
+
5
+ test('ClientSummarizer parses Anthropic responses', async () => {
6
+ const originalFetch = globalThis.fetch;
7
+ globalThis.fetch = (async () =>
8
+ new Response(JSON.stringify({
9
+ content: [
10
+ {
11
+ type: 'text',
12
+ text: JSON.stringify({
13
+ summary: 'Added OAuth2 PKCE flow to auth service',
14
+ concepts: ['oauth', 'pkce', 'authentication'],
15
+ details: { title: 'OAuth2 PKCE', subtitle: 'Added PKCE flow' },
16
+ }),
17
+ },
18
+ ],
19
+ }), {
20
+ status: 200,
21
+ headers: { 'content-type': 'application/json' },
22
+ })) as typeof fetch;
23
+
24
+ try {
25
+ const summarizer = new ClientSummarizer({
26
+ mode: 'anthropic',
27
+ apiKey: 'sk-ant-test-fake',
28
+ });
29
+
30
+ const result = await summarizer.summarize('Implemented OAuth2 with PKCE', 'observation');
31
+
32
+ assert.ok(result);
33
+ assert.equal(result.summary, 'Added OAuth2 PKCE flow to auth service');
34
+ assert.deepEqual(result.concepts, ['oauth', 'pkce', 'authentication']);
35
+ assert.equal(result.summary_details?.title, 'OAuth2 PKCE');
36
+ } finally {
37
+ globalThis.fetch = originalFetch;
38
+ }
39
+ });
40
+
41
+ test('ClientSummarizer parses native Claude JSON output', async () => {
42
+ const summarizer = new ClientSummarizer({
43
+ mode: 'native-claude',
44
+ runCommand: async ({ command }) => {
45
+ assert.equal(command, 'claude');
46
+ return JSON.stringify({
47
+ result: '{"summary":"Verified auth middleware refresh flow.","concepts":["auth","middleware","refresh"],"details":{"title":"Auth middleware refresh"}}',
48
+ });
49
+ },
50
+ });
51
+
52
+ const result = await summarizer.summarize('Inspected auth middleware', 'observation');
53
+
54
+ assert.ok(result);
55
+ assert.equal(result.summary, 'Verified auth middleware refresh flow.');
56
+ assert.deepEqual(result.concepts, ['auth', 'middleware', 'refresh']);
57
+ });
58
+
59
+ test('ClientSummarizer parses native Codex JSONL output', async () => {
60
+ const summarizer = new ClientSummarizer({
61
+ mode: 'native-codex',
62
+ runCommand: async ({ command }) => {
63
+ assert.equal(command, 'codex');
64
+ return [
65
+ '{"type":"thread.started","thread_id":"abc"}',
66
+ '{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"thinking"}}',
67
+ '{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"{\\"summary\\":\\"Captured OAuth callback fix.\\",\\"concepts\\":[\\"oauth\\",\\"callback\\"],\\"details\\":{\\"title\\":\\"OAuth callback\\"}}"}}',
68
+ ].join('\n');
69
+ },
70
+ });
71
+
72
+ const result = await summarizer.summarize('Fixed OAuth callback edge case', 'attempt');
73
+
74
+ assert.ok(result);
75
+ assert.equal(result.summary, 'Captured OAuth callback fix.');
76
+ assert.deepEqual(result.concepts, ['oauth', 'callback']);
77
+ assert.equal(result.summary_details?.title, 'OAuth callback');
78
+ });
79
+
80
+ test('ClientSummarizer returns null on command failure', async () => {
81
+ const summarizer = new ClientSummarizer({
82
+ mode: 'native-codex',
83
+ runCommand: async () => {
84
+ throw new Error('boom');
85
+ },
86
+ });
87
+
88
+ const result = await summarizer.summarize('test', 'observation');
89
+ assert.equal(result, null);
90
+ });
91
+
92
+ test('ClientSummarizer truncates summary to 180 chars', async () => {
93
+ const summarizer = new ClientSummarizer({
94
+ mode: 'native-claude',
95
+ runCommand: async () => JSON.stringify({
96
+ result: JSON.stringify({
97
+ summary: 'A'.repeat(300),
98
+ concepts: ['test'],
99
+ }),
100
+ }),
101
+ });
102
+
103
+ const result = await summarizer.summarize('test', 'observation');
104
+ assert.ok(result);
105
+ assert.equal(result.summary.length, 180);
106
+ });
107
+
108
+ test('ClientSummarizer limits concepts to 12', async () => {
109
+ const summarizer = new ClientSummarizer({
110
+ mode: 'native-codex',
111
+ runCommand: async () => [
112
+ '{"type":"item.completed","item":{"type":"agent_message","text":"{\\"summary\\":\\"test\\",\\"concepts\\":[' +
113
+ Array.from({ length: 20 }, (_, i) => `\\"concept${i}\\"`).join(',') +
114
+ ']}"}}',
115
+ ].join('\n'),
116
+ });
117
+
118
+ const result = await summarizer.summarize('test', 'observation');
119
+ assert.ok(result);
120
+ assert.equal(result.concepts.length, 12);
121
+ });
122
+
123
+ test('ClientSummarizer handles session_summary details', async () => {
124
+ const summarizer = new ClientSummarizer({
125
+ mode: 'native-claude',
126
+ runCommand: async () => JSON.stringify({
127
+ result: JSON.stringify({
128
+ summary: 'Implemented auth middleware',
129
+ concepts: ['auth', 'middleware'],
130
+ details: {
131
+ request: 'Add WorkOS auth',
132
+ investigated: ['existing auth flow'],
133
+ completed: ['WorkOS integration'],
134
+ next_steps: ['add RBAC'],
135
+ },
136
+ }),
137
+ }),
138
+ });
139
+
140
+ const result = await summarizer.summarize('session content', 'session_summary');
141
+ assert.ok(result);
142
+ assert.equal(result.summary_details?.request, 'Add WorkOS auth');
143
+ assert.deepEqual(result.summary_details?.completed, ['WorkOS integration']);
144
+ });