@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
|
package/assets/agents/builder.md
CHANGED
|
@@ -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
|
-
**
|
|
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,27 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Hook command -
|
|
3
|
+
* Hook command - Claude Code hook handlers
|
|
4
4
|
*
|
|
5
|
-
* Purpose:
|
|
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
|
-
* -
|
|
9
|
-
* - Cross-platform
|
|
10
|
-
* -
|
|
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
|
-
|
|
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('
|
|
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
|
|
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
|
|
46
|
-
throw new Error(
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
};
|