@magclaw/cli-core 0.1.37 → 0.1.38

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.
@@ -0,0 +1,319 @@
1
+ import crypto from 'node:crypto';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ const CODEX_HOOK_EVENTS = Object.freeze(['Stop', 'PreCompact', 'SessionStart']);
6
+ const CLAUDE_HOOK_EVENTS = Object.freeze(['Stop', 'SessionEnd', 'PreCompact', 'SessionStart']);
7
+
8
+ function asArray(value) {
9
+ return Array.isArray(value) ? value : [];
10
+ }
11
+
12
+ function stableHash(value = '') {
13
+ return crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 16);
14
+ }
15
+
16
+ function compactWhitespace(value = '') {
17
+ return String(value || '').replace(/\s+/g, ' ').trim();
18
+ }
19
+
20
+ function redactTeamMemoryText(value = '') {
21
+ return compactWhitespace(value)
22
+ .replace(/(?:api[_-]?key|token|secret|password)\s*[:=]\s*["']?[^\s"',;)]+/gi, '[redacted-secret]')
23
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/gi, 'Bearer [redacted-secret]')
24
+ .replace(/(App Secret|app_secret|client_secret)(\s*[::=]\s*)[^\s"',;)]+/gi, '$1$2[redacted-secret]');
25
+ }
26
+
27
+ function iso(value, fallback = new Date().toISOString()) {
28
+ const date = new Date(value || fallback);
29
+ return Number.isNaN(date.getTime()) ? fallback : date.toISOString();
30
+ }
31
+
32
+ function parseJsonOrJsonl(text = '') {
33
+ const trimmed = String(text || '').trim();
34
+ if (!trimmed) return [];
35
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
36
+ try {
37
+ const parsed = JSON.parse(trimmed);
38
+ return Array.isArray(parsed) ? parsed : [parsed];
39
+ } catch {
40
+ // Fall through to JSONL parsing. Codex transcripts are newline-delimited
41
+ // JSON objects and usually start with "{".
42
+ }
43
+ }
44
+ return trimmed.split(/\r?\n/)
45
+ .map((line) => line.trim())
46
+ .filter(Boolean)
47
+ .map((line) => JSON.parse(line));
48
+ }
49
+
50
+ function isInjectedCodexContext(text = '') {
51
+ const clean = String(text || '').trim();
52
+ return clean.startsWith('# AGENTS.md instructions for ')
53
+ || clean.startsWith('<environment_context>')
54
+ || clean.startsWith('<permissions instructions>')
55
+ || clean.startsWith('<skills_instructions>')
56
+ || clean.startsWith('<plugins_instructions>');
57
+ }
58
+
59
+ function textFromContentBlocks(content) {
60
+ const blocks = asArray(content);
61
+ if (!blocks.length && typeof content === 'string') return content;
62
+ return blocks
63
+ .map((block) => {
64
+ if (!block || typeof block !== 'object') return '';
65
+ return block.text || block.content || '';
66
+ })
67
+ .filter((text) => text && !isInjectedCodexContext(text))
68
+ .join('\n\n');
69
+ }
70
+
71
+ function pushUnique(target, value) {
72
+ const clean = String(value || '').trim();
73
+ if (clean && !target.includes(clean)) target.push(clean);
74
+ }
75
+
76
+ function normalizeRuntime(value = '') {
77
+ const runtime = String(value || '').trim().toLowerCase();
78
+ if (runtime === 'claude' || runtime === 'claude-code') return 'claude_code';
79
+ if (runtime === 'codex') return 'codex';
80
+ return runtime || 'codex';
81
+ }
82
+
83
+ function codexTextEvent(item, context) {
84
+ if (item?.type === 'session_meta' && item.payload) {
85
+ context.sessionId = context.sessionId || item.payload.id || item.payload.session_id || '';
86
+ context.projectPath = context.projectPath || item.payload.cwd || '';
87
+ context.title = context.title || item.payload.title || '';
88
+ return null;
89
+ }
90
+ if (item?.type !== 'response_item') return null;
91
+ const payload = item.payload || {};
92
+ if (payload.type === 'function_call' || payload.type === 'custom_tool_call') {
93
+ pushUnique(context.toolNames, payload.name);
94
+ return null;
95
+ }
96
+ if (payload.type !== 'message') return null;
97
+ const role = String(payload.role || '').toLowerCase();
98
+ if (!['user', 'assistant'].includes(role)) return null;
99
+ const text = redactTeamMemoryText(textFromContentBlocks(payload.content));
100
+ if (!text) return null;
101
+ return {
102
+ role,
103
+ text,
104
+ createdAt: iso(item.timestamp),
105
+ toolCalls: role === 'assistant' && context.toolNames.length
106
+ ? context.toolNames.map((name) => ({ name }))
107
+ : [],
108
+ };
109
+ }
110
+
111
+ function claudeTextEvent(item, context) {
112
+ const raw = item?.payload && typeof item.payload === 'object' ? item.payload : item;
113
+ if (raw?.type === 'system' && raw.subtype === 'init') {
114
+ context.sessionId = context.sessionId || raw.session_id || raw.sessionId || '';
115
+ context.projectPath = context.projectPath || raw.cwd || '';
116
+ return null;
117
+ }
118
+ if (raw?.type === 'assistant' || raw?.type === 'user') {
119
+ const textBlocks = asArray(raw.message?.content)
120
+ .map((block) => (block?.type === 'text' ? block.text : ''))
121
+ .filter(Boolean);
122
+ const text = redactTeamMemoryText(textBlocks.join('\n\n'));
123
+ if (!text) return null;
124
+ return {
125
+ role: raw.type === 'assistant' ? 'assistant' : 'user',
126
+ text,
127
+ createdAt: iso(item.timestamp || raw.timestamp),
128
+ toolCalls: raw.type === 'assistant' && context.toolNames.length
129
+ ? context.toolNames.map((name) => ({ name }))
130
+ : [],
131
+ };
132
+ }
133
+ for (const block of asArray(raw?.message?.content)) {
134
+ if (block?.type === 'tool_use') pushUnique(context.toolNames, block.name);
135
+ }
136
+ return null;
137
+ }
138
+
139
+ export function parseTeamMemoryTranscript(text = '', options = {}) {
140
+ const runtime = normalizeRuntime(options.runtime);
141
+ const parsed = parseJsonOrJsonl(text);
142
+ const context = {
143
+ runtime,
144
+ sessionId: String(options.sessionId || '').trim(),
145
+ projectPath: String(options.projectPath || options.projectDir || '').trim(),
146
+ title: String(options.title || '').trim(),
147
+ toolNames: [],
148
+ };
149
+ const events = [];
150
+ for (const item of parsed) {
151
+ const extracted = runtime === 'claude_code'
152
+ ? claudeTextEvent(item, context)
153
+ : codexTextEvent(item, context);
154
+ if (!extracted) continue;
155
+ const ordinal = events.length + 1;
156
+ events.push({
157
+ eventId: `${context.sessionId || options.sessionId || 'session'}:${ordinal}:${stableHash(`${extracted.role}:${extracted.text}`)}`,
158
+ ordinal,
159
+ role: extracted.role,
160
+ text: extracted.text,
161
+ createdAt: extracted.createdAt,
162
+ sourceHash: stableHash(extracted.text),
163
+ sourceAnchor: `${context.sessionId || options.sessionId || 'session'}#${ordinal}`,
164
+ toolCalls: extracted.toolCalls,
165
+ });
166
+ }
167
+ if (!context.title) {
168
+ context.title = events.find((event) => event.role === 'user')?.text?.slice(0, 80)
169
+ || events.find((event) => event.role === 'assistant')?.text?.slice(0, 80)
170
+ || `${runtime} session ${context.sessionId || stableHash(text)}`;
171
+ }
172
+ if (!context.sessionId) context.sessionId = stableHash(`${runtime}:${context.projectPath}:${text}`);
173
+ return {
174
+ runtime,
175
+ sessionId: context.sessionId,
176
+ projectPath: context.projectPath,
177
+ title: context.title,
178
+ toolNames: context.toolNames,
179
+ events,
180
+ };
181
+ }
182
+
183
+ export function buildTeamMemorySyncPackageFromTranscript(text = '', options = {}) {
184
+ const runtime = normalizeRuntime(options.runtime);
185
+ const parsed = parseTeamMemoryTranscript(text, options);
186
+ const lastOrdinal = Math.max(0, Number(options.lastOrdinal || 0));
187
+ const minCreatedAt = String(options.minCreatedAt || '').trim();
188
+ const incrementalEvents = parsed.events
189
+ .filter((event) => Number(event.ordinal || 0) > lastOrdinal)
190
+ .filter((event) => !minCreatedAt || String(event.createdAt || '') >= minCreatedAt);
191
+ if (!incrementalEvents.length) {
192
+ return {
193
+ ok: true,
194
+ empty: true,
195
+ body: null,
196
+ cursor: {
197
+ runtime,
198
+ sessionId: parsed.sessionId,
199
+ lastOrdinal,
200
+ },
201
+ };
202
+ }
203
+ const fromOrdinal = incrementalEvents[0].ordinal;
204
+ const toOrdinal = incrementalEvents[incrementalEvents.length - 1].ordinal;
205
+ const projectKey = String(options.projectKey || path.basename(parsed.projectPath || process.cwd()) || 'default').trim();
206
+ const batchHash = stableHash(JSON.stringify(incrementalEvents.map((event) => ({
207
+ eventId: event.eventId,
208
+ sourceHash: event.sourceHash,
209
+ }))));
210
+ const body = {
211
+ runtime,
212
+ projectKey,
213
+ projectPathHash: stableHash(parsed.projectPath || projectKey),
214
+ sessionId: parsed.sessionId,
215
+ title: options.title || parsed.title,
216
+ workspaceId: options.workspaceId || '',
217
+ channelId: options.channelId || '',
218
+ channelPath: options.channelPath || '',
219
+ fromOrdinal,
220
+ toOrdinal,
221
+ idempotencyKey: `${runtime}:${projectKey}:${parsed.sessionId}:${fromOrdinal}:${toOrdinal}:${batchHash}`,
222
+ optionalLocalDigest: [
223
+ options.localDigest || '',
224
+ parsed.toolNames.length ? `Tool summary: ${parsed.toolNames.join(', ')}` : '',
225
+ ].filter(Boolean).join('\n'),
226
+ events: incrementalEvents,
227
+ createdAt: options.now?.() || new Date().toISOString(),
228
+ };
229
+ return {
230
+ ok: true,
231
+ empty: false,
232
+ body,
233
+ cursor: {
234
+ runtime,
235
+ sessionId: parsed.sessionId,
236
+ lastOrdinal: toOrdinal,
237
+ lastEventId: incrementalEvents[incrementalEvents.length - 1].eventId,
238
+ updatedAt: body.createdAt,
239
+ },
240
+ };
241
+ }
242
+
243
+ export function shouldRunTeamMemoryHook({ runtime = 'codex', hookEventName = '' } = {}) {
244
+ const normalized = normalizeRuntime(runtime);
245
+ const event = String(hookEventName || '').trim();
246
+ const allowed = normalized === 'claude_code' ? CLAUDE_HOOK_EVENTS : CODEX_HOOK_EVENTS;
247
+ return allowed.includes(event);
248
+ }
249
+
250
+ function shellQuote(value = '') {
251
+ return `'${String(value || '').replace(/'/g, "'\\''")}'`;
252
+ }
253
+
254
+ export function buildTeamMemoryHookCommand(options = {}) {
255
+ const runtime = normalizeRuntime(options.runtime);
256
+ const hookEventName = String(options.hookEventName || (runtime === 'claude_code' ? 'SessionEnd' : 'Stop')).trim();
257
+ const transcriptPath = options.transcriptPath || (runtime === 'claude_code' ? '${CLAUDE_TRANSCRIPT_PATH:-}' : '${CODEX_SESSION_FILE:-}');
258
+ const parts = [
259
+ 'magclaw',
260
+ 'memory',
261
+ 'sync',
262
+ '--runtime',
263
+ runtime,
264
+ '--hook-event',
265
+ hookEventName,
266
+ '--transcript',
267
+ transcriptPath.includes('${') ? `"${transcriptPath}"` : shellQuote(transcriptPath),
268
+ ];
269
+ if (options.integration) parts.push('--integration', String(options.integration).replace(/[^a-zA-Z0-9._-]+/g, '-'));
270
+ if (options.projectDir) parts.push('--cwd', shellQuote(options.projectDir));
271
+ return parts.join(' ');
272
+ }
273
+
274
+ function hookEventsForRuntime(runtime) {
275
+ return normalizeRuntime(runtime) === 'claude_code' ? CLAUDE_HOOK_EVENTS : CODEX_HOOK_EVENTS;
276
+ }
277
+
278
+ async function readJson(file, fallback = {}) {
279
+ try {
280
+ return JSON.parse(await readFile(file, 'utf8'));
281
+ } catch {
282
+ return fallback;
283
+ }
284
+ }
285
+
286
+ export async function installTeamMemoryHookConfig(options = {}) {
287
+ const runtime = normalizeRuntime(options.runtime);
288
+ const configPath = String(options.configPath || '').trim();
289
+ if (!configPath) throw new Error('configPath is required.');
290
+ const config = await readJson(configPath, {});
291
+ config.hooks = config.hooks && typeof config.hooks === 'object' ? config.hooks : {};
292
+ const installed = [];
293
+ for (const hookEventName of hookEventsForRuntime(runtime)) {
294
+ const command = buildTeamMemoryHookCommand({ ...options, runtime, hookEventName });
295
+ const entries = asArray(config.hooks[hookEventName]);
296
+ const entry = entries[0] || { hooks: [] };
297
+ entry.hooks = asArray(entry.hooks);
298
+ const exists = entry.hooks.some((hook) => String(hook?.command || '').includes('magclaw memory sync')
299
+ && String(hook?.command || '').includes(`--runtime ${runtime}`)
300
+ && String(hook?.command || '').includes(`--hook-event ${hookEventName}`));
301
+ if (!exists) {
302
+ entry.hooks.push({
303
+ type: 'command',
304
+ command,
305
+ timeout: hookEventName === 'SessionStart' ? 3 : 15,
306
+ });
307
+ installed.push(hookEventName);
308
+ }
309
+ config.hooks[hookEventName] = entries.length ? entries : [entry];
310
+ }
311
+ await mkdir(path.dirname(configPath), { recursive: true });
312
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
313
+ return {
314
+ ok: true,
315
+ runtime,
316
+ configPath,
317
+ installed,
318
+ };
319
+ }