Swadhin Biswas feat: implement mervelas cli engine, built-in tools, and reactive UI · 4/3/2026
0358986
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
import { feature } from 'bun:bundle'
import { normalize, posix, win32 } from 'path'
import {
getAutoMemPath,
getMemoryBaseDir,
isAutoMemoryEnabled,
isAutoMemPath,
} from '../memdir/paths.js'
import { isAgentMemoryPath } from '../tools/AgentTool/agentMemory.js'
import { getClaudeConfigHomeDir } from './envUtils.js'
import {
posixPathToWindowsPath,
windowsPathToPosixPath,
} from './windowsPaths.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const teamMemPaths = feature('TEAMMEM')
? (require('../memdir/teamMemPaths.js') as typeof import('../memdir/teamMemPaths.js'))
: null
/* eslint-enable @typescript-eslint/no-require-imports */
const IS_WINDOWS = process.platform === 'win32'
// Normalize path separators to posix (/). Does NOT translate drive encoding.
function toPosix(p: string): string {
return p.split(win32.sep).join(posix.sep)
}
// Convert a path to a stable string-comparable form: forward-slash separated,
// and on Windows, lowercased (Windows filesystems are case-insensitive).
function toComparable(p: string): string {
const posixForm = toPosix(p)
return IS_WINDOWS ? posixForm.toLowerCase() : posixForm
}
/**
* Detects if a file path is a session-related file under ~/.claude.
* Returns the type of session file or null if not a session file.
*/
export function detectSessionFileType(
filePath: string,
): 'session_memory' | 'session_transcript' | null {
const configDir = getClaudeConfigHomeDir()
// Compare in forward-slash form; on Windows also case-fold. The caller
// (isShellCommandTargetingMemory) converts MinGW /c/... → native before
// reaching here, so we only need separator + case normalization.
const normalized = toComparable(filePath)
const configDirCmp = toComparable(configDir)
if (!normalized.startsWith(configDirCmp)) {
return null
}
if (normalized.includes('/session-memory/') && normalized.endsWith('.md')) {
return 'session_memory'
}
if (normalized.includes('/projects/') && normalized.endsWith('.jsonl')) {
return 'session_transcript'
}
return null
}
/**
* Checks if a glob/pattern string indicates session file access intent.
* Used for Grep/Glob tools where we check patterns, not actual file paths.
*/
export function detectSessionPatternType(
pattern: string,
): 'session_memory' | 'session_transcript' | null {
const normalized = pattern.split(win32.sep).join(posix.sep)
if (
normalized.includes('session-memory') &&
(normalized.includes('.md') || normalized.endsWith('*'))
) {
return 'session_memory'
}
if (
normalized.includes('.jsonl') ||
(normalized.includes('projects') && normalized.includes('*.jsonl'))
) {
return 'session_transcript'
}
return null
}
/**
* Check if a file path is within the memdir directory.
*/
export function isAutoMemFile(filePath: string): boolean {
if (isAutoMemoryEnabled()) {
return isAutoMemPath(filePath)
}
return false
}
export type MemoryScope = 'personal' | 'team'
/**
* Determine which memory store (if any) a path belongs to.
*
* Team dir is a subdirectory of memdir (getTeamMemPath = join(getAutoMemPath, 'team')),
* so a team path matches both isTeamMemFile and isAutoMemFile. Check team first.
*
* Use this for scope-keyed telemetry where a single event name distinguishes
* by scope field — the existing tengu_memdir_* / tengu_team_mem_* event-name
* hierarchy handles the overlap differently (team writes intentionally fire both).
*/
export function memoryScopeForPath(filePath: string): MemoryScope | null {
if (feature('TEAMMEM') && teamMemPaths!.isTeamMemFile(filePath)) {
return 'team'
}
if (isAutoMemFile(filePath)) {
return 'personal'
}
return null
}
/**
* Check if a file path is within an agent memory directory.
*/
function isAgentMemFile(filePath: string): boolean {
if (isAutoMemoryEnabled()) {
return isAgentMemoryPath(filePath)
}
return false
}
/**
* Check if a file is a Claude-managed memory file (NOT user-managed instruction files).
* Includes: auto-memory (memdir), agent memory, session memory/transcripts.
* Excludes: MERVELAS.md, MERVELAS.local.md, .mervelas/rules/*.md (user-managed).
*
* Use this for collapse/badge logic where user-managed files should show full diffs.
*/
export function isAutoManagedMemoryFile(filePath: string): boolean {
if (isAutoMemFile(filePath)) {
return true
}
if (feature('TEAMMEM') && teamMemPaths!.isTeamMemFile(filePath)) {
return true
}
if (detectSessionFileType(filePath) !== null) {
return true
}
if (isAgentMemFile(filePath)) {
return true
}
return false
}
// Check if a directory path is a memory-related directory.
// Used by Grep/Glob which take a directory `path` rather than a specific file.
// Checks both configDir and memoryBaseDir to handle custom memory dir paths.
export function isMemoryDirectory(dirPath: string): boolean {
// SECURITY: Normalize to prevent path traversal bypasses via .. segments.
// On Windows this produces backslashes; toComparable flips them back for
// string matching. MinGW /c/... paths are converted to native before
// reaching here (extraction-time in isShellCommandTargetingMemory), so
// normalize() never sees them.
const normalizedPath = normalize(dirPath)
const normalizedCmp = toComparable(normalizedPath)
// Agent memory directories can be under cwd (project scope), configDir, or memoryBaseDir
if (
isAutoMemoryEnabled() &&
(normalizedCmp.includes('/agent-memory/') ||
normalizedCmp.includes('/agent-memory-local/'))
) {
return true
}
// Team memory directories live under <autoMemPath>/team/
if (
feature('TEAMMEM') &&
teamMemPaths!.isTeamMemoryEnabled() &&
teamMemPaths!.isTeamMemPath(normalizedPath)
) {
return true
}
// Check the auto-memory path override (CLAUDE_COWORK_MEMORY_PATH_OVERRIDE)
if (isAutoMemoryEnabled()) {
const autoMemPath = getAutoMemPath()
const autoMemDirCmp = toComparable(autoMemPath.replace(/[/\\]+$/, ''))
const autoMemPathCmp = toComparable(autoMemPath)
if (
normalizedCmp === autoMemDirCmp ||
normalizedCmp.startsWith(autoMemPathCmp)
) {
return true
}
}
const configDirCmp = toComparable(getClaudeConfigHomeDir())
const memoryBaseCmp = toComparable(getMemoryBaseDir())
const underConfig = normalizedCmp.startsWith(configDirCmp)
const underMemoryBase = normalizedCmp.startsWith(memoryBaseCmp)
if (!underConfig && !underMemoryBase) {
return false
}
if (normalizedCmp.includes('/session-memory/')) {
return true
}
if (underConfig && normalizedCmp.includes('/projects/')) {
return true
}
if (isAutoMemoryEnabled() && normalizedCmp.includes('/memory/')) {
return true
}
return false
}
/**
* Check if a shell command string (Bash or PowerShell) targets memory files
* by extracting absolute path tokens and checking them against memory
* detection functions. Used for Bash/PowerShell grep/search commands in the
* collapse logic.
*/
export function isShellCommandTargetingMemory(command: string): boolean {
const configDir = getClaudeConfigHomeDir()
const memoryBase = getMemoryBaseDir()
const autoMemDir = isAutoMemoryEnabled()
? getAutoMemPath().replace(/[/\\]+$/, '')
: ''
// Quick check: does the command mention the config, memory base, or
// auto-mem directory? Compare in forward-slash form (PowerShell on Windows
// may use either separator while configDir uses the platform-native one).
// On Windows also check the MinGW form (/c/...) since BashTool runs under
// Git Bash which emits that encoding. On Linux/Mac, configDir is already
// posix so only one form to check — and crucially, windowsPathToPosixPath
// is NOT called, so Linux paths like /m/foo aren't misinterpreted as MinGW.
const commandCmp = toComparable(command)
const dirs = [configDir, memoryBase, autoMemDir].filter(Boolean)
const matchesAnyDir = dirs.some(d => {
if (commandCmp.includes(toComparable(d))) return true
if (IS_WINDOWS) {
// BashTool on Windows (Git Bash) emits /c/Users/... — check MinGW form too
return commandCmp.includes(windowsPathToPosixPath(d).toLowerCase())
}
return false
})
if (!matchesAnyDir) {
return false
}
// Extract absolute path-like tokens. Matches Unix absolute paths (/foo/bar),
// Windows drive-letter paths (C:\foo, C:/foo), and MinGW paths (/c/foo —
// they're /-prefixed so the regex already captures them). Bare backslash
// tokens (\foo) are intentionally excluded — they appear in regex/grep
// patterns and would cause false-positive memory classification after
// normalization flips backslashes to forward slashes.
const matches = command.match(/(?:[A-Za-z]:[/\\]|\/)[^\s'"]+/g)
if (!matches) {
return false
}
for (const match of matches) {
// Strip trailing shell metacharacters that could be adjacent to a path
const cleanPath = match.replace(/[,;|&>]+$/, '')
// On Windows, convert MinGW /c/... → native C:\... at this single
// point. Downstream predicates (isAutoManagedMemoryFile, isMemoryDirectory,
// isAutoMemPath, isAgentMemoryPath) then receive native paths and only
// need toComparable() for matching. On other platforms, paths are already
// native — no conversion, so /m/foo etc. pass through unmodified.
const nativePath = IS_WINDOWS
? posixPathToWindowsPath(cleanPath)
: cleanPath
if (isAutoManagedMemoryFile(nativePath) || isMemoryDirectory(nativePath)) {
return true
}
}
return false
}
// Check if a glob/pattern targets auto-managed memory files only.
// Excludes MERVELAS.md, MERVELAS.local.md, .mervelas/rules/ (user-managed).
// Used for collapse badge logic where user-managed files should not be
// counted as "memory" operations.
export function isAutoManagedMemoryPattern(pattern: string): boolean {
if (detectSessionPatternType(pattern) !== null) {
return true
}
if (
isAutoMemoryEnabled() &&
(pattern.replace(/\\/g, '/').includes('agent-memory/') ||
pattern.replace(/\\/g, '/').includes('agent-memory-local/'))
) {
return true
}
return false
}