@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.
- package/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-test.log +45 -0
- package/cursor-hooks/README.md +119 -0
- package/cursor-hooks/context-inject.sh +118 -0
- package/cursor-hooks/hooks.json +39 -0
- package/cursor-hooks/save-file-edit.sh +130 -0
- package/cursor-hooks/save-observation.sh +116 -0
- package/cursor-hooks/save-shell-execution.sh +121 -0
- package/cursor-hooks/session-summary.sh +142 -0
- package/dist/capture.d.ts +111 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.integration.d.ts +2 -0
- package/dist/capture.integration.d.ts.map +1 -0
- package/dist/capture.integration.js +67 -0
- package/dist/capture.integration.js.map +1 -0
- package/dist/capture.js +264 -0
- package/dist/capture.js.map +1 -0
- package/dist/client-summarizer.d.ts +59 -0
- package/dist/client-summarizer.d.ts.map +1 -0
- package/dist/client-summarizer.js +211 -0
- package/dist/client-summarizer.js.map +1 -0
- package/dist/client-summarizer.test.d.ts +2 -0
- package/dist/client-summarizer.test.d.ts.map +1 -0
- package/dist/client-summarizer.test.js +127 -0
- package/dist/client-summarizer.test.js.map +1 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +131 -0
- package/dist/config.js.map +1 -0
- package/dist/config.test.d.ts +2 -0
- package/dist/config.test.d.ts.map +1 -0
- package/dist/config.test.js +182 -0
- package/dist/config.test.js.map +1 -0
- package/dist/cursor-hooks.d.ts +46 -0
- package/dist/cursor-hooks.d.ts.map +1 -0
- package/dist/cursor-hooks.js +251 -0
- package/dist/cursor-hooks.js.map +1 -0
- package/dist/cursor-rules.d.ts +42 -0
- package/dist/cursor-rules.d.ts.map +1 -0
- package/dist/cursor-rules.js +229 -0
- package/dist/cursor-rules.js.map +1 -0
- package/dist/cursor-rules.test.d.ts +2 -0
- package/dist/cursor-rules.test.d.ts.map +1 -0
- package/dist/cursor-rules.test.js +55 -0
- package/dist/cursor-rules.test.js.map +1 -0
- package/dist/dedup.d.ts +22 -0
- package/dist/dedup.d.ts.map +1 -0
- package/dist/dedup.js +60 -0
- package/dist/dedup.js.map +1 -0
- package/dist/dedup.test.d.ts +2 -0
- package/dist/dedup.test.d.ts.map +1 -0
- package/dist/dedup.test.js +83 -0
- package/dist/dedup.test.js.map +1 -0
- package/dist/hooks/index.d.ts +52 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +136 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks.test.d.ts +2 -0
- package/dist/hooks.test.d.ts.map +1 -0
- package/dist/hooks.test.js +94 -0
- package/dist/hooks.test.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/strip-private.d.ts +12 -0
- package/dist/strip-private.d.ts.map +1 -0
- package/dist/strip-private.js +28 -0
- package/dist/strip-private.js.map +1 -0
- package/dist/strip-private.test.d.ts +2 -0
- package/dist/strip-private.test.d.ts.map +1 -0
- package/dist/strip-private.test.js +37 -0
- package/dist/strip-private.test.js.map +1 -0
- package/dist/utils.d.ts +3 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +11 -0
- package/dist/utils.js.map +1 -0
- package/package.json +28 -0
- package/src/capture.integration.ts +98 -0
- package/src/capture.ts +352 -0
- package/src/client-summarizer.test.ts +144 -0
- package/src/client-summarizer.ts +338 -0
- package/src/config.test.ts +211 -0
- package/src/config.ts +157 -0
- package/src/cursor-hooks.ts +309 -0
- package/src/cursor-rules.test.ts +63 -0
- package/src/cursor-rules.ts +313 -0
- package/src/dedup.test.ts +84 -0
- package/src/dedup.ts +67 -0
- package/src/hooks/index.ts +226 -0
- package/src/hooks.test.ts +111 -0
- package/src/index.ts +32 -0
- package/src/strip-private.test.ts +57 -0
- package/src/strip-private.ts +34 -0
- package/src/utils.ts +10 -0
- package/tsconfig.json +12 -0
- 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
|
+
});
|