memorix 1.8.8 → 1.8.9

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.
@@ -30,6 +30,10 @@ function resolveHookCommand(): string {
30
30
  return 'memorix';
31
31
  }
32
32
 
33
+ function getGrokHome(): string {
34
+ return process.env.GROK_HOME?.trim() || path.join(os.homedir(), '.grok');
35
+ }
36
+
33
37
  function resolveWindowsMemorixShim(): string | null {
34
38
  try {
35
39
  const output = String(execSync('where.exe memorix.cmd', {
@@ -83,6 +87,114 @@ function generateClaudeConfig(): Record<string, unknown> {
83
87
  };
84
88
  }
85
89
 
90
+ /**
91
+ * Generate Grok Build hook config.
92
+ * Format: ~/.grok/hooks/memorix.json (own file; do not merge into RuleSync dests)
93
+ * Grok uses its official JSON hook format with Claude-compatible event names. Command is `memorix hook` (a
94
+ * shell command, not a relative path — Grok prefixes non-absolute commands
95
+ * with the JSON file directory).
96
+ * See: https://docs.x.ai grok hooks user guide
97
+ */
98
+ function generateGrokConfig(): Record<string, unknown> {
99
+ const cmd = `${resolveHookCommand()} hook --agent grok`;
100
+ const hookEntry = {
101
+ type: 'command',
102
+ command: cmd,
103
+ timeout: 10,
104
+ };
105
+
106
+ return {
107
+ hooks: {
108
+ SessionStart: [{ hooks: [hookEntry] }],
109
+ SessionEnd: [{ hooks: [hookEntry] }],
110
+ PostToolUse: [{ hooks: [hookEntry] }],
111
+ PostToolUseFailure: [{ hooks: [hookEntry] }],
112
+ UserPromptSubmit: [{ hooks: [hookEntry] }],
113
+ PreCompact: [{ hooks: [hookEntry] }],
114
+ PostCompact: [{ hooks: [hookEntry] }],
115
+ Stop: [{ hooks: [hookEntry] }],
116
+ StopFailure: [{ hooks: [hookEntry] }],
117
+ },
118
+ };
119
+ }
120
+
121
+ function isJsonRecord(value: unknown): value is Record<string, unknown> {
122
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
123
+ }
124
+
125
+ function isMemorixGrokCommand(value: unknown): boolean {
126
+ return value === 'memorix hook --agent grok' || value === 'memorix.cmd hook --agent grok';
127
+ }
128
+
129
+ function isMemorixGrokHook(value: unknown): boolean {
130
+ return isJsonRecord(value) && value.type === 'command' && isMemorixGrokCommand(value.command);
131
+ }
132
+
133
+ function mergeGrokHookConfig(
134
+ existing: Record<string, unknown>,
135
+ generated: Record<string, unknown>,
136
+ ): Record<string, unknown> {
137
+ const merged = { ...existing };
138
+ const existingHooks = isJsonRecord(existing.hooks) ? { ...existing.hooks } : {};
139
+ const generatedHooks = isJsonRecord(generated.hooks) ? generated.hooks : {};
140
+
141
+ for (const [event, generatedGroups] of Object.entries(generatedHooks)) {
142
+ const currentGroups = Array.isArray(existingHooks[event]) ? existingHooks[event] : [];
143
+ const preservedGroups = currentGroups.flatMap((group) => {
144
+ if (!isJsonRecord(group) || !Array.isArray(group.hooks)) return [group];
145
+ const preservedHooks = group.hooks.filter((hook) => !isMemorixGrokHook(hook));
146
+ if (preservedHooks.length === 0) return [];
147
+ return [{ ...group, hooks: preservedHooks }];
148
+ });
149
+ existingHooks[event] = [...preservedGroups, ...(Array.isArray(generatedGroups) ? generatedGroups : [])];
150
+ }
151
+
152
+ merged.hooks = existingHooks;
153
+ return merged;
154
+ }
155
+
156
+ function removeMemorixGrokHooks(config: Record<string, unknown>): Record<string, unknown> {
157
+ const cleaned = { ...config };
158
+ const hooks = isJsonRecord(config.hooks) ? { ...config.hooks } : null;
159
+ if (!hooks) return cleaned;
160
+
161
+ for (const [event, groups] of Object.entries(hooks)) {
162
+ if (!Array.isArray(groups)) continue;
163
+ const preservedGroups = groups.flatMap((group) => {
164
+ if (!isJsonRecord(group) || !Array.isArray(group.hooks)) return [group];
165
+ const preservedHooks = group.hooks.filter((hook) => !isMemorixGrokHook(hook));
166
+ if (preservedHooks.length === 0) return [];
167
+ return [{ ...group, hooks: preservedHooks }];
168
+ });
169
+ if (preservedGroups.length > 0) hooks[event] = preservedGroups;
170
+ else delete hooks[event];
171
+ }
172
+
173
+ if (Object.keys(hooks).length > 0) cleaned.hooks = hooks;
174
+ else delete cleaned.hooks;
175
+ return cleaned;
176
+ }
177
+
178
+ async function readGrokHookConfig(configPath: string): Promise<Record<string, unknown>> {
179
+ let content: string;
180
+ try {
181
+ content = await fs.readFile(configPath, 'utf-8');
182
+ } catch (error) {
183
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
184
+ throw error;
185
+ }
186
+
187
+ if (!content.trim()) return {};
188
+ try {
189
+ const parsed: unknown = JSON.parse(content);
190
+ if (!isJsonRecord(parsed)) throw new Error('top-level value must be an object');
191
+ return parsed;
192
+ } catch (error) {
193
+ const reason = error instanceof Error ? error.message : String(error);
194
+ throw new Error(`Cannot safely update Grok hook config ${configPath}: ${reason}`);
195
+ }
196
+ }
197
+
86
198
  /**
87
199
  * Generate GitHub Copilot hook config.
88
200
  * Format: .github/hooks/memorix.json — version:1 + bash/powershell fields
@@ -658,6 +770,8 @@ export function getProjectConfigPath(agent: AgentName, projectRoot: string): str
658
770
  // uninstall all treat hooks as a no-op instead of falling through to the
659
771
  // `.memorix/hooks.json` default (which would report a path that never exists).
660
772
  return '';
773
+ case 'grok':
774
+ return path.join(projectRoot, '.grok', 'hooks', 'memorix.json');
661
775
  default:
662
776
  return path.join(projectRoot, '.memorix', 'hooks.json');
663
777
  }
@@ -706,6 +820,8 @@ export function getGlobalConfigPath(agent: AgentName): string {
706
820
  case 'workbuddy':
707
821
  // WorkBuddy has no user-global instructions file — guidance is project-only.
708
822
  return '';
823
+ case 'grok':
824
+ return path.join(getGrokHome(), 'hooks', 'memorix.json');
709
825
  default:
710
826
  return path.join(home, '.memorix', 'hooks.json');
711
827
  }
@@ -734,6 +850,8 @@ export function getAgentRulesPath(agent: AgentName, root: string, global = false
734
850
  case 'dsh':
735
851
  // The user-global AGENTS.md lives in the harness home DSH reads.
736
852
  return path.join(process.env.DSH_HOME?.trim() || path.join(root, '.dsh'), 'AGENTS.md');
853
+ case 'grok':
854
+ return path.join(getGrokHome(), 'AGENTS.md');
737
855
  case 'workbuddy':
738
856
  // WorkBuddy has no user-global instructions file — guidance is project-only.
739
857
  return '';
@@ -766,6 +884,8 @@ export function getAgentRulesPath(agent: AgentName, root: string, global = false
766
884
  return path.join(root, 'AGENTS.md');
767
885
  case 'workbuddy':
768
886
  return path.join(root, 'AGENTS.md');
887
+ case 'grok':
888
+ return path.join(root, 'AGENTS.md');
769
889
  default:
770
890
  return path.join(root, '.agent', 'rules', 'memorix.md');
771
891
  }
@@ -837,6 +957,13 @@ export async function detectInstalledAgents(): Promise<AgentName[]> {
837
957
  agents.push('kiro');
838
958
  } catch { /* not installed */ }
839
959
 
960
+ // Check for Grok Build
961
+ const grokDir = getGrokHome();
962
+ try {
963
+ await fs.access(grokDir);
964
+ agents.push('grok');
965
+ } catch { /* not installed */ }
966
+
840
967
  // Check for Codex
841
968
  const codexDir = path.join(home, '.codex');
842
969
  try {
@@ -953,6 +1080,12 @@ export async function installHooks(
953
1080
  if (basename === 'AGENTS.md' || basename === 'GEMINI.md' || basename === 'CONTEXT.md') {
954
1081
  continue;
955
1082
  }
1083
+ // Grok's dedicated JSON file may also contain user-owned hook groups.
1084
+ // Its merge/uninstall path removes only Memorix commands, so never
1085
+ // delete the whole file during a reinstall cleanup.
1086
+ if (agent === 'grok' && path.resolve(entry.path) === path.resolve(configPath)) {
1087
+ continue;
1088
+ }
956
1089
  await unlink(entry.path);
957
1090
  await removeFile(projectRoot, entry.path);
958
1091
  } catch { /* file already gone */ }
@@ -965,6 +1098,9 @@ export async function installHooks(
965
1098
  case 'claude':
966
1099
  generated = generateClaudeConfig();
967
1100
  break;
1101
+ case 'grok':
1102
+ generated = generateGrokConfig();
1103
+ break;
968
1104
  case 'copilot':
969
1105
  generated = generateCopilotConfig();
970
1106
  break;
@@ -1080,7 +1216,19 @@ export async function installHooks(
1080
1216
  // Ensure directory exists
1081
1217
  await fs.mkdir(path.dirname(configPath), { recursive: true });
1082
1218
 
1083
- if (agent === 'kiro') {
1219
+ if (agent === 'grok') {
1220
+ // Grok discovers one file per hook source and does not require the
1221
+ // version field used by Cursor. Preserve any user-owned hook groups in
1222
+ // this file and replace only Memorix's own command entries.
1223
+ const existing = await readGrokHookConfig(configPath);
1224
+ const merged = mergeGrokHookConfig(existing, generated as Record<string, unknown>);
1225
+ await fs.writeFile(configPath, JSON.stringify(merged, null, 2), 'utf-8');
1226
+
1227
+ try {
1228
+ const { recordFile } = await import('../../audit/index.js');
1229
+ await recordFile(projectRoot, 'hook', configPath, agent);
1230
+ } catch { /* audit is optional */ }
1231
+ } else if (agent === 'kiro') {
1084
1232
  // Kiro uses multiple .kiro.hook files
1085
1233
  const hookFiles = generateKiroHookFiles();
1086
1234
  const hooksDir = path.join(path.dirname(configPath));
@@ -1186,6 +1334,9 @@ export async function installHooks(
1186
1334
  case 'kiro':
1187
1335
  events.push('session_end', 'user_prompt', 'post_edit');
1188
1336
  break;
1337
+ case 'grok':
1338
+ events.push('session_start', 'post_tool', 'user_prompt', 'pre_compact', 'post_compact', 'session_end');
1339
+ break;
1189
1340
  }
1190
1341
 
1191
1342
  // Install agent rules alongside hooks
@@ -1217,7 +1368,7 @@ async function installAgentRules(agent: AgentName, projectRoot: string, global =
1217
1368
  try {
1218
1369
  await fs.mkdir(path.dirname(rulesPath), { recursive: true });
1219
1370
 
1220
- if (agent === 'claude' || agent === 'codex' || agent === 'opencode' || agent === 'antigravity' || agent === 'gemini-cli' || agent === 'dsh') {
1371
+ if (agent === 'claude' || agent === 'codex' || agent === 'opencode' || agent === 'antigravity' || agent === 'gemini-cli' || agent === 'dsh' || agent === 'grok') {
1221
1372
  // For shared context files (CLAUDE.md / AGENTS.md / GEMINI.md), append rather than overwrite.
1222
1373
  try {
1223
1374
  const existing = await fs.readFile(rulesPath, 'utf-8');
@@ -1422,7 +1573,16 @@ export async function uninstallHooks(
1422
1573
  let success = false;
1423
1574
 
1424
1575
  try {
1425
- if (agent === 'kiro' || agent === 'opencode') {
1576
+ if (agent === 'grok') {
1577
+ const config = await readGrokHookConfig(configPath);
1578
+ const cleaned = removeMemorixGrokHooks(config);
1579
+ if (Object.keys(cleaned).length === 0) {
1580
+ await fs.unlink(configPath);
1581
+ } else {
1582
+ await fs.writeFile(configPath, JSON.stringify(cleaned, null, 2), 'utf-8');
1583
+ }
1584
+ success = true;
1585
+ } else if (agent === 'kiro' || agent === 'opencode') {
1426
1586
  await fs.unlink(configPath);
1427
1587
  success = true;
1428
1588
  } else {
@@ -1448,6 +1608,14 @@ export async function uninstallHooks(
1448
1608
  const agentFiles = prevFiles.filter(e => e.agent === agent);
1449
1609
  for (const entry of agentFiles) {
1450
1610
  try {
1611
+ // The Grok hook file can contain user-owned groups. The dedicated
1612
+ // config pass above already removed Memorix entries safely; only
1613
+ // forget the audit record here, never unlink the shared file.
1614
+ if (agent === 'grok' && path.resolve(entry.path) === path.resolve(configPath)) {
1615
+ await removeFile(projectRoot, entry.path);
1616
+ auditCleaned = true;
1617
+ continue;
1618
+ }
1451
1619
  const basename = path.basename(entry.path);
1452
1620
  // Shared context files: remove only the Memorix block, not the whole file
1453
1621
  if (basename === 'AGENTS.md' || basename === 'GEMINI.md' || basename === 'CONTEXT.md') {
@@ -1530,7 +1698,7 @@ export async function getHookStatus(
1530
1698
  projectRoot: string,
1531
1699
  ): Promise<Array<{ agent: AgentName; installed: boolean; outdated: boolean; verified: boolean; runtimeReady: boolean; configPath: string }>> {
1532
1700
  const results: Array<{ agent: AgentName; installed: boolean; outdated: boolean; verified: boolean; runtimeReady: boolean; configPath: string }> = [];
1533
- const agents: AgentName[] = ['claude', 'copilot', 'windsurf', 'cursor', 'kiro', 'codex', 'antigravity', 'gemini-cli', 'opencode', 'pi', 'trae'];
1701
+ const agents: AgentName[] = ['claude', 'copilot', 'windsurf', 'cursor', 'kiro', 'codex', 'antigravity', 'gemini-cli', 'opencode', 'pi', 'trae', 'grok'];
1534
1702
 
1535
1703
  for (const agent of agents) {
1536
1704
  const projectPath = getProjectConfigPath(agent, projectRoot);
@@ -1542,7 +1710,9 @@ export async function getHookStatus(
1542
1710
 
1543
1711
  // Config-based agents: file existence = verified (agent reads config directly)
1544
1712
  // Plugin/package agents (OpenCode, Pi): file existence alone does not prove runtime load.
1545
- const verifiedByDefault = agent !== 'opencode' && agent !== 'pi';
1713
+ // A Grok project hook can still be skipped until the host grants
1714
+ // `/hooks-trust`; only its always-trusted global file is verified by path.
1715
+ const verifiedByDefault = agent !== 'opencode' && agent !== 'pi' && agent !== 'grok';
1546
1716
 
1547
1717
  try {
1548
1718
  await fs.access(projectPath);
@@ -1576,11 +1746,16 @@ export async function getHookStatus(
1576
1746
  runtimeReady = detectPwsh();
1577
1747
  }
1578
1748
 
1749
+ const verified = installed && (
1750
+ verifiedByDefault ||
1751
+ (agent === 'grok' && usedPath === globalPath)
1752
+ );
1753
+
1579
1754
  results.push({
1580
1755
  agent,
1581
1756
  installed,
1582
1757
  outdated,
1583
- verified: installed && verifiedByDefault,
1758
+ verified,
1584
1759
  runtimeReady,
1585
1760
  configPath: usedPath,
1586
1761
  });
@@ -20,6 +20,7 @@ const EVENT_MAP: Record<string, HookEvent> = {
20
20
  post_command: 'post_command',
21
21
  post_tool: 'post_tool',
22
22
  pre_compact: 'pre_compact',
23
+ post_compact: 'post_compact',
23
24
  session_end: 'session_end',
24
25
  post_response: 'post_response',
25
26
 
@@ -32,6 +33,13 @@ const EVENT_MAP: Record<string, HookEvent> = {
32
33
  Stop: 'session_end',
33
34
  SessionEnd: 'session_end',
34
35
 
36
+ // Grok Build (Claude-compatible PascalCase plus snake_case hookEventName)
37
+ user_prompt_submit: 'user_prompt',
38
+ pre_tool_use: 'post_tool',
39
+ post_tool_use: 'post_tool',
40
+ post_tool_use_failure: 'post_tool',
41
+ stop_failure: 'session_end',
42
+
35
43
  // GitHub Copilot (camelCase, different names from Cursor)
36
44
  userPromptSubmitted: 'user_prompt',
37
45
  preToolUse: 'post_tool',
@@ -197,8 +205,48 @@ function normalizeCompactionMetadata(
197
205
  }
198
206
 
199
207
  /**
200
- * Detect which agent sent this hook event based on payload structure.
208
+ * Normalize the official Grok Build hook payload.
201
209
  */
210
+ function normalizeGrok(payload: Record<string, unknown>, event: HookEvent): Partial<NormalizedHookInput> {
211
+ const result: Partial<NormalizedHookInput> = {
212
+ sessionId: firstString(payload.sessionId, payload.session_id, process.env.GROK_SESSION_ID) ?? '',
213
+ cwd: firstString(payload.cwd, payload.workspaceRoot, payload.workspace_root, process.env.GROK_WORKSPACE_ROOT) ?? '',
214
+ transcriptPath: firstString(payload.transcriptPath, payload.transcript_path),
215
+ };
216
+ const toolName = firstString(payload.toolName, payload.tool_name);
217
+ if (toolName) {
218
+ result.toolName = toolName;
219
+ const toolInput = asRecord(payload.toolInput) ?? asRecord(payload.tool_input);
220
+ result.toolInput = toolInput;
221
+ if (/^(bash|run_terminal_command)$/i.test(toolName)) {
222
+ result.command = firstString(toolInput?.command, toolInput?.cmd);
223
+ }
224
+ if (/^(write|edit|multi_edit|multiedittool|search_replace)$/i.test(toolName)) {
225
+ result.filePath = firstString(toolInput?.path, toolInput?.file_path, toolInput?.filePath);
226
+ }
227
+ result.toolResult = stringifyValue(
228
+ payload.toolResult ?? payload.tool_response ?? payload.tool_result ?? payload.error,
229
+ );
230
+ }
231
+ if (event === 'user_prompt') {
232
+ result.userPrompt = firstString(payload.prompt, payload.userPrompt, payload.user_prompt) ?? '';
233
+ }
234
+ if (event === 'post_response' || event === 'session_end') {
235
+ result.aiResponse = firstString(
236
+ payload.lastAssistantMessage,
237
+ payload.last_assistant_message,
238
+ payload.assistantMessage,
239
+ payload.assistant_message,
240
+ payload.aiResponse,
241
+ payload.ai_response,
242
+ );
243
+ }
244
+ if (event === 'session_start') {
245
+ result.sessionStartReason = firstString(payload.source, payload.sessionStartReason, payload.session_start_reason);
246
+ }
247
+ return result;
248
+ }
249
+
202
250
  function detectAgent(payload: Record<string, unknown>): AgentName {
203
251
  // Highest priority: explicit agent identity injected by memorix hook --agent flag
204
252
  // This is set by generated hook configs when host payloads are ambiguous.
@@ -222,6 +270,12 @@ function detectAgent(payload: Record<string, unknown>): AgentName {
222
270
  if (payload.agent === 'omp') return 'omp';
223
271
  if (payload.agent === 'hermes') return 'hermes';
224
272
  if (payload.agent === 'openclaw') return 'openclaw';
273
+ if (payload.agent === 'grok') return 'grok';
274
+
275
+ // Grok Build uses camelCase hookEventName + sessionId
276
+ if ('hookEventName' in payload && ('sessionId' in payload || 'GROK_SESSION_ID' in process.env)) {
277
+ return 'grok';
278
+ }
225
279
 
226
280
  // Claude Code uses hook_event_name + session_id
227
281
  if ('hook_event_name' in payload && 'session_id' in payload) return 'claude';
@@ -286,6 +340,8 @@ function extractEventName(payload: Record<string, unknown>, agent: AgentName): s
286
340
  return (payload.hook_event_name as string) ?? '';
287
341
  case 'kiro':
288
342
  return (payload.event_type as string) ?? '';
343
+ case 'grok':
344
+ return (payload.hookEventName as string) ?? (payload.hook_event_name as string) ?? '';
289
345
  default:
290
346
  return '';
291
347
  }
@@ -734,6 +790,9 @@ export function normalizeHookInput(payload: Record<string, unknown>): Normalized
734
790
  case 'codebuddy':
735
791
  agentSpecific = normalizeClaude(payload, event);
736
792
  break;
793
+ case 'grok':
794
+ agentSpecific = normalizeGrok(payload, event);
795
+ break;
737
796
  default:
738
797
  agentSpecific = { sessionId: '', cwd: '' };
739
798
  }
@@ -44,7 +44,8 @@ export type AgentName =
44
44
  | 'dsh'
45
45
  | 'workbuddy'
46
46
  | 'pi'
47
- | 'omp';
47
+ | 'omp'
48
+ | 'grok';
48
49
 
49
50
  /** Support tier for hook agents */
50
51
  export type SupportTier = 'core' | 'extended' | 'community';
@@ -68,6 +69,7 @@ export const AGENT_SUPPORT_TIER: Record<AgentName, SupportTier> = {
68
69
  pi: 'community',
69
70
  omp: 'community',
70
71
  antigravity: 'community',
72
+ grok: 'community',
71
73
  };
72
74
 
73
75
  /** Normalized hook input — agent-agnostic */
@@ -65,6 +65,15 @@ export const MEMORIX_INTEGRATIONS: MemorixIntegration[] = [
65
65
  install: 'memorix setup --agent copilot',
66
66
  surfaces: ['MCP', 'skills', 'hooks'],
67
67
  },
68
+ {
69
+ agent: 'grok',
70
+ name: 'Grok Build',
71
+ entry: 'official-config',
72
+ status: 'ready',
73
+ install: 'memorix setup --agent grok --global',
74
+ surfaces: ['hooks', 'AGENTS.md'],
75
+ note: 'Writes ~/.grok/hooks/memorix.json and leaves Grok MCP host-owned; setup does not write config.toml.',
76
+ },
68
77
  {
69
78
  agent: 'cursor',
70
79
  name: 'Cursor',