@sylphx/flow 3.19.1 → 3.20.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @sylphx/flow
2
2
 
3
+ ## 3.20.0 (2026-02-08)
4
+
5
+ ### ✨ Features
6
+
7
+ - **flow:** add SessionStart memory hook and use stdin for notifications ([6580500](https://github.com/SylphxAI/flow/commit/65805004f164a8f9a05d821e528c7412f7573e2f))
8
+
3
9
  ## 3.19.1 (2026-02-07)
4
10
 
5
11
  ### 🐛 Bug Fixes
@@ -101,13 +101,22 @@ State-of-the-art industrial standard. Every time. Would you stake your reputatio
101
101
 
102
102
  ## Memory
103
103
 
104
+ Two-layer durable memory:
105
+
106
+ - **`MEMORY.md`** — Curated long-term memory. Decisions, preferences, durable facts.
107
+ - **`memory/YYYY-MM-DD.md`** — Daily log (append-only). Running context, day-to-day notes.
108
+
109
+ **Rules:**
110
+ - If someone says "remember this," write it down immediately (do not keep it in RAM).
111
+ - Decisions and preferences → `MEMORY.md`
112
+ - Day-to-day notes and running context → `memory/YYYY-MM-DD.md`
113
+ - SessionStart hook auto-loads MEMORY.md + today/yesterday daily logs.
114
+
104
115
  **Atomic commits.** Commit continuously. Each commit = one logical change. Semantic commit messages (feat, fix, docs, refactor, test, chore). This is your memory of what was done.
105
116
 
106
117
  **Todos.** Use TaskCreate/TaskUpdate to track what needs to be done. This is your memory of what to do.
107
118
 
108
- **CLAUDE.md** Your persistent memory file. Commands, env setup, architecture decisions, patterns, gotchas. Read first. Summarize, don't append. Remove resolved. Consolidate duplicates.
109
-
110
- **Recovery:** Lost context? → `git log`. Forgot next steps? → TaskList.
119
+ **Recovery:** Lost context? `git log`. Forgot next steps? TaskList. Need old memories? read `memory/` directory.
111
120
 
112
121
  ## Issue Ownership
113
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/flow",
3
- "version": "3.19.1",
3
+ "version": "3.20.0",
4
4
  "description": "One CLI to rule them all. Unified orchestration layer for AI coding assistants. Auto-detection, auto-installation, auto-upgrade.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,27 +1,50 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Hook command - OS notification for Claude Code startup
3
+ * Hook command - Claude Code hook handlers
4
4
  *
5
- * Purpose: Send OS-level notifications when Claude Code starts
5
+ * Purpose: Handle Claude Code lifecycle hooks:
6
+ * - notification: OS-level notification when Claude Code starts
7
+ * - session-start: Load durable memory (MEMORY.md + recent daily logs)
6
8
  *
7
9
  * DESIGN RATIONALE:
8
- * - Simple notification: Just notify user when Claude Code is ready
9
- * - Cross-platform: Supports macOS, Linux, and Windows
10
- * - Non-intrusive: Fails silently if notification system not available
10
+ * - Each hook type returns content to stdout (Claude Code injects as context)
11
+ * - Cross-platform notifications (macOS, Linux, Windows)
12
+ * - SessionStart hook loads cross-session memory at startup
11
13
  */
12
14
 
13
15
  import { exec } from 'node:child_process';
16
+ import { readFile } from 'node:fs/promises';
14
17
  import os from 'node:os';
18
+ import path from 'node:path';
15
19
  import { promisify } from 'node:util';
16
20
  import { Command } from 'commander';
17
21
  import { cli } from '../utils/display/cli-output.js';
18
22
 
23
+ /**
24
+ * Hook input from Claude Code via stdin.
25
+ * Claude Code sends JSON context about the event — we read what we need.
26
+ */
27
+ interface HookInput {
28
+ /** Notification message text */
29
+ message?: string;
30
+ /** Notification title */
31
+ title?: string;
32
+ /** Notification type (permission_prompt, idle_prompt, etc.) */
33
+ notification_type?: string;
34
+ /** How the session started (startup, resume, clear, compact) */
35
+ source?: string;
36
+ /** Current working directory */
37
+ cwd?: string;
38
+ }
39
+
19
40
  const execAsync = promisify(exec);
20
41
 
21
42
  /**
22
- * Hook types supported
43
+ * Hook types supported — single source of truth for both type and runtime validation
23
44
  */
24
- type HookType = 'notification';
45
+ const VALID_HOOK_TYPES = ['notification', 'session-start'] as const;
46
+ type HookType = (typeof VALID_HOOK_TYPES)[number];
47
+ const VALID_HOOK_TYPE_SET = new Set<string>(VALID_HOOK_TYPES);
25
48
 
26
49
  /**
27
50
  * Target platforms supported
@@ -32,18 +55,20 @@ type TargetPlatform = 'claude-code';
32
55
  * Create the hook command
33
56
  */
34
57
  export const hookCommand = new Command('hook')
35
- .description('Load dynamic system information for Claude Code hooks')
36
- .requiredOption('--type <type>', 'Hook type (notification)')
58
+ .description('Handle Claude Code lifecycle hooks (notification, memory)')
59
+ .requiredOption('--type <type>', 'Hook type (notification | session-start)')
37
60
  .option('--target <target>', 'Target platform (claude-code)', 'claude-code')
38
61
  .option('--verbose', 'Show verbose output', false)
39
62
  .action(async (options) => {
40
63
  try {
41
- const hookType = options.type as HookType;
64
+ const hookType = options.type as string;
42
65
  const target = options.target as TargetPlatform;
43
66
 
44
67
  // Validate hook type
45
- if (hookType !== 'notification') {
46
- throw new Error(`Invalid hook type: ${hookType}. Must be 'notification'`);
68
+ if (!VALID_HOOK_TYPE_SET.has(hookType)) {
69
+ throw new Error(
70
+ `Invalid hook type: ${hookType}. Must be one of: ${VALID_HOOK_TYPES.join(', ')}`
71
+ );
47
72
  }
48
73
 
49
74
  // Validate target
@@ -51,8 +76,11 @@ export const hookCommand = new Command('hook')
51
76
  throw new Error(`Invalid target: ${target}. Only 'claude-code' is currently supported`);
52
77
  }
53
78
 
79
+ // Read hook input from stdin (Claude Code passes JSON context)
80
+ const hookInput = await readStdinInput();
81
+
54
82
  // Load and display content based on hook type
55
- const content = await loadHookContent(hookType, target, options.verbose);
83
+ const content = await loadHookContent(hookType as HookType, target, hookInput, options.verbose);
56
84
 
57
85
  // Output the content (no extra formatting, just the content)
58
86
  console.log(content);
@@ -72,27 +100,140 @@ export const hookCommand = new Command('hook')
72
100
  }
73
101
  });
74
102
 
103
+ /**
104
+ * Read JSON input from stdin (non-blocking, returns empty object if no input)
105
+ * Claude Code sends event context as JSON on stdin for all hook types.
106
+ */
107
+ async function readStdinInput(): Promise<HookInput> {
108
+ // stdin is not a TTY when piped from Claude Code
109
+ if (process.stdin.isTTY) {
110
+ return {};
111
+ }
112
+
113
+ return new Promise((resolve) => {
114
+ let data = '';
115
+ process.stdin.setEncoding('utf-8');
116
+ process.stdin.on('data', (chunk) => {
117
+ data += chunk;
118
+ });
119
+ process.stdin.on('end', () => {
120
+ if (!data.trim()) {
121
+ resolve({});
122
+ return;
123
+ }
124
+ try {
125
+ resolve(JSON.parse(data) as HookInput);
126
+ } catch {
127
+ resolve({});
128
+ }
129
+ });
130
+ // Safety timeout — don't hang if stdin never closes
131
+ setTimeout(() => resolve({}), 1000);
132
+ });
133
+ }
134
+
75
135
  /**
76
136
  * Load content for a specific hook type and target
77
137
  */
78
138
  async function loadHookContent(
79
139
  hookType: HookType,
80
140
  _target: TargetPlatform,
141
+ input: HookInput,
81
142
  verbose: boolean = false
82
143
  ): Promise<string> {
83
- if (hookType === 'notification') {
84
- return await sendNotification(verbose);
144
+ switch (hookType) {
145
+ case 'notification':
146
+ return await sendNotification(input, verbose);
147
+ case 'session-start':
148
+ return await loadSessionStartContent(verbose);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Read a file and return its contents, or empty string if not found
154
+ */
155
+ async function readFileIfExists(filePath: string): Promise<string> {
156
+ try {
157
+ return await readFile(filePath, 'utf-8');
158
+ } catch {
159
+ return '';
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Format a date as YYYY-MM-DD
165
+ */
166
+ function formatDate(date: Date): string {
167
+ const year = date.getFullYear();
168
+ const month = String(date.getMonth() + 1).padStart(2, '0');
169
+ const day = String(date.getDate()).padStart(2, '0');
170
+ return `${year}-${month}-${day}`;
171
+ }
172
+
173
+ /**
174
+ * Load memory files for session start:
175
+ * - MEMORY.md (curated long-term memory)
176
+ * - memory/{today}.md (today's daily log)
177
+ * - memory/{yesterday}.md (yesterday's daily log)
178
+ *
179
+ * Returns concatenated content to stdout for Claude Code context injection
180
+ */
181
+ async function loadSessionStartContent(verbose: boolean): Promise<string> {
182
+ const cwd = process.cwd();
183
+ const sections: string[] = [];
184
+
185
+ // Load MEMORY.md
186
+ const memoryPath = path.join(cwd, 'MEMORY.md');
187
+ const memoryContent = await readFileIfExists(memoryPath);
188
+ if (memoryContent.trim()) {
189
+ sections.push(`## MEMORY.md\n${memoryContent.trim()}`);
190
+ if (verbose) {
191
+ cli.info('Loaded MEMORY.md');
192
+ }
193
+ }
194
+
195
+ // Calculate today and yesterday dates
196
+ const today = new Date();
197
+ const yesterday = new Date(today);
198
+ yesterday.setDate(yesterday.getDate() - 1);
199
+
200
+ const todayStr = formatDate(today);
201
+ const yesterdayStr = formatDate(yesterday);
202
+
203
+ // Load today's daily log
204
+ const todayPath = path.join(cwd, 'memory', `${todayStr}.md`);
205
+ const todayContent = await readFileIfExists(todayPath);
206
+ if (todayContent.trim()) {
207
+ sections.push(`## memory/${todayStr}.md\n${todayContent.trim()}`);
208
+ if (verbose) {
209
+ cli.info(`Loaded memory/${todayStr}.md`);
210
+ }
211
+ }
212
+
213
+ // Load yesterday's daily log
214
+ const yesterdayPath = path.join(cwd, 'memory', `${yesterdayStr}.md`);
215
+ const yesterdayContent = await readFileIfExists(yesterdayPath);
216
+ if (yesterdayContent.trim()) {
217
+ sections.push(`## memory/${yesterdayStr}.md\n${yesterdayContent.trim()}`);
218
+ if (verbose) {
219
+ cli.info(`Loaded memory/${yesterdayStr}.md`);
220
+ }
221
+ }
222
+
223
+ if (verbose && sections.length === 0) {
224
+ cli.info('No memory files found');
85
225
  }
86
226
 
87
- return '';
227
+ return sections.join('\n\n');
88
228
  }
89
229
 
90
230
  /**
91
- * Send OS-level notification
231
+ * Send OS-level notification using event data from Claude Code.
232
+ * Falls back to generic message when stdin input is missing.
92
233
  */
93
- async function sendNotification(verbose: boolean): Promise<string> {
94
- const title = '🔮 Sylphx Flow';
95
- const message = 'Claude Code is ready';
234
+ async function sendNotification(input: HookInput, verbose: boolean): Promise<string> {
235
+ const title = input.title || '🔮 Sylphx Flow';
236
+ const message = input.message || 'Claude Code is ready';
96
237
  const platform = os.platform();
97
238
 
98
239
  if (verbose) {
@@ -66,6 +66,7 @@ const DEFAULT_CLAUDE_CODE_SETTINGS: Partial<ClaudeCodeSettings> = {
66
66
 
67
67
  export interface HookConfig {
68
68
  notificationCommand?: string;
69
+ sessionStartCommand?: string;
69
70
  }
70
71
 
71
72
  /**
@@ -75,15 +76,16 @@ export interface HookConfig {
75
76
  export const generateHookCommands = async (targetId: string): Promise<HookConfig> => {
76
77
  return {
77
78
  notificationCommand: `sylphx-flow hook --type notification --target ${targetId}`,
79
+ sessionStartCommand: `sylphx-flow hook --type session-start --target ${targetId}`,
78
80
  };
79
81
  };
80
82
 
81
83
  /**
82
84
  * Default hook commands (fallback)
83
- * Simplified to only include notification hook
84
85
  */
85
86
  const DEFAULT_HOOKS: HookConfig = {
86
87
  notificationCommand: 'sylphx-flow hook --type notification --target claude-code',
88
+ sessionStartCommand: 'sylphx-flow hook --type session-start --target claude-code',
87
89
  };
88
90
 
89
91
  /**
@@ -94,17 +96,19 @@ export const processSettings = (
94
96
  hookConfig: HookConfig = DEFAULT_HOOKS
95
97
  ): Result<string, ConfigError> => {
96
98
  const notificationCommand = hookConfig.notificationCommand || DEFAULT_HOOKS.notificationCommand!;
99
+ const sessionStartCommand = hookConfig.sessionStartCommand || DEFAULT_HOOKS.sessionStartCommand!;
97
100
 
98
101
  const hookConfiguration: ClaudeCodeSettings['hooks'] = {
99
102
  Notification: [
100
103
  {
101
104
  matcher: '',
102
- hooks: [
103
- {
104
- type: 'command',
105
- command: notificationCommand,
106
- },
107
- ],
105
+ hooks: [{ type: 'command', command: notificationCommand }],
106
+ },
107
+ ],
108
+ SessionStart: [
109
+ {
110
+ matcher: '',
111
+ hooks: [{ type: 'command', command: sessionStartCommand }],
108
112
  },
109
113
  ],
110
114
  };