@tonycasey/lisa 0.5.13

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.
Files changed (48) hide show
  1. package/README.md +42 -0
  2. package/dist/cli.js +390 -0
  3. package/dist/lib/interfaces/IDockerClient.js +2 -0
  4. package/dist/lib/interfaces/IMcpClient.js +2 -0
  5. package/dist/lib/interfaces/IServices.js +2 -0
  6. package/dist/lib/interfaces/ITemplateCopier.js +2 -0
  7. package/dist/lib/mcp.js +35 -0
  8. package/dist/lib/services.js +57 -0
  9. package/dist/package.json +36 -0
  10. package/dist/templates/agents/.sample.env +12 -0
  11. package/dist/templates/agents/docs/STORAGE_SETUP.md +161 -0
  12. package/dist/templates/agents/skills/common/group-id.js +193 -0
  13. package/dist/templates/agents/skills/init-review/SKILL.md +119 -0
  14. package/dist/templates/agents/skills/init-review/scripts/ai-enrich.js +258 -0
  15. package/dist/templates/agents/skills/init-review/scripts/init-review.js +769 -0
  16. package/dist/templates/agents/skills/lisa/SKILL.md +92 -0
  17. package/dist/templates/agents/skills/lisa/cache/.gitkeep +0 -0
  18. package/dist/templates/agents/skills/lisa/scripts/storage.js +374 -0
  19. package/dist/templates/agents/skills/memory/SKILL.md +31 -0
  20. package/dist/templates/agents/skills/memory/scripts/memory.js +533 -0
  21. package/dist/templates/agents/skills/prompt/SKILL.md +19 -0
  22. package/dist/templates/agents/skills/prompt/scripts/prompt.js +184 -0
  23. package/dist/templates/agents/skills/tasks/SKILL.md +31 -0
  24. package/dist/templates/agents/skills/tasks/scripts/tasks.js +489 -0
  25. package/dist/templates/claude/config.js +40 -0
  26. package/dist/templates/claude/hooks/README.md +158 -0
  27. package/dist/templates/claude/hooks/common/complexity-rater.js +290 -0
  28. package/dist/templates/claude/hooks/common/context.js +263 -0
  29. package/dist/templates/claude/hooks/common/group-id.js +188 -0
  30. package/dist/templates/claude/hooks/common/mcp-client.js +131 -0
  31. package/dist/templates/claude/hooks/common/transcript-parser.js +256 -0
  32. package/dist/templates/claude/hooks/common/zep-client.js +175 -0
  33. package/dist/templates/claude/hooks/session-start.js +401 -0
  34. package/dist/templates/claude/hooks/session-stop-worker.js +341 -0
  35. package/dist/templates/claude/hooks/session-stop.js +122 -0
  36. package/dist/templates/claude/hooks/user-prompt-submit.js +256 -0
  37. package/dist/templates/claude/settings.json +46 -0
  38. package/dist/templates/docker/.env.lisa.example +17 -0
  39. package/dist/templates/docker/docker-compose.graphiti.yml +45 -0
  40. package/dist/templates/rules/shared/clean-architecture.md +333 -0
  41. package/dist/templates/rules/shared/code-quality-rules.md +469 -0
  42. package/dist/templates/rules/shared/git-rules.md +64 -0
  43. package/dist/templates/rules/shared/testing-principles.md +469 -0
  44. package/dist/templates/rules/typescript/coding-standards.md +751 -0
  45. package/dist/templates/rules/typescript/testing.md +629 -0
  46. package/dist/templates/rules/typescript/typescript-config-guide.md +465 -0
  47. package/package.json +64 -0
  48. package/scripts/postinstall.js +710 -0
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const MAX_GROUP_ID_LENGTH = 128;
7
+ // ============================================================================
8
+ // Group ID Functions
9
+ // ============================================================================
10
+ /**
11
+ * Normalize a path to a valid group ID string.
12
+ * /Users/tony.casey/Repos/api -> users-tony.casey-repos-api
13
+ */
14
+ function normalizePathToGroupId(absolutePath) {
15
+ let normalized = absolutePath
16
+ .toLowerCase()
17
+ .replace(/^\//, '') // Remove leading slash
18
+ .replace(/\//g, '-') // Replace slashes with dashes
19
+ .replace(/\./g, '_'); // Replace dots with underscores (Graphiti requires alphanumeric, dashes, underscores only)
20
+ // Truncate if too long (keep the end which is most specific)
21
+ if (normalized.length > MAX_GROUP_ID_LENGTH) {
22
+ normalized = normalized.slice(-MAX_GROUP_ID_LENGTH);
23
+ }
24
+ return normalized;
25
+ }
26
+ /**
27
+ * Get the current folder's group ID.
28
+ */
29
+ function getCurrentGroupId(cwd = process.cwd()) {
30
+ return normalizePathToGroupId(cwd);
31
+ }
32
+ /**
33
+ * Get hierarchical group IDs from current folder up to home directory.
34
+ * Returns array ordered from most specific (current) to least specific (home).
35
+ *
36
+ * Example for /Users/tony/Repos/api/src:
37
+ * ['users-tony-repos-api-src', 'users-tony-repos-api', 'users-tony-repos', 'users-tony']
38
+ */
39
+ function getHierarchicalGroupIds(cwd = process.cwd()) {
40
+ const homeDir = os.homedir();
41
+ const groups = [];
42
+ let currentPath = path.resolve(cwd);
43
+ // Walk up the directory tree until we reach home or root
44
+ while (currentPath.length >= homeDir.length) {
45
+ groups.push(normalizePathToGroupId(currentPath));
46
+ // Stop at home directory
47
+ if (currentPath === homeDir) {
48
+ break;
49
+ }
50
+ const parentPath = path.dirname(currentPath);
51
+ // Stop if we can't go up anymore (reached root)
52
+ if (parentPath === currentPath) {
53
+ break;
54
+ }
55
+ currentPath = parentPath;
56
+ }
57
+ return groups;
58
+ }
59
+ /**
60
+ * Detect folder metadata by checking for project markers and file types.
61
+ */
62
+ function detectFolderMetadata(cwd = process.cwd()) {
63
+ const groupId = getCurrentGroupId(cwd);
64
+ const base = { path: cwd, groupId, type: 'unknown' };
65
+ // Check for Node.js/TypeScript project
66
+ const packageJsonPath = path.join(cwd, 'package.json');
67
+ if (fs.existsSync(packageJsonPath)) {
68
+ try {
69
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
70
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
71
+ // Detect framework
72
+ let framework;
73
+ if (deps.next)
74
+ framework = 'nextjs';
75
+ else if (deps.react)
76
+ framework = 'react';
77
+ else if (deps.express)
78
+ framework = 'express';
79
+ else if (deps.fastify)
80
+ framework = 'fastify';
81
+ else if (deps.hono)
82
+ framework = 'hono';
83
+ else if (deps.vue)
84
+ framework = 'vue';
85
+ else if (deps.angular)
86
+ framework = 'angular';
87
+ else if (deps.svelte)
88
+ framework = 'svelte';
89
+ // Detect if TypeScript
90
+ const isTypeScript = deps.typescript || fs.existsSync(path.join(cwd, 'tsconfig.json'));
91
+ return {
92
+ ...base,
93
+ type: 'project',
94
+ projectType: isTypeScript ? 'typescript' : 'javascript',
95
+ framework,
96
+ description: pkg.description,
97
+ };
98
+ }
99
+ catch (_err) {
100
+ // Invalid package.json, continue detection
101
+ }
102
+ }
103
+ // Check for Python project
104
+ if (fs.existsSync(path.join(cwd, 'pyproject.toml')) || fs.existsSync(path.join(cwd, 'setup.py'))) {
105
+ let framework;
106
+ const reqPath = path.join(cwd, 'requirements.txt');
107
+ if (fs.existsSync(reqPath)) {
108
+ try {
109
+ const reqs = fs.readFileSync(reqPath, 'utf8').toLowerCase();
110
+ if (reqs.includes('fastapi'))
111
+ framework = 'fastapi';
112
+ else if (reqs.includes('django'))
113
+ framework = 'django';
114
+ else if (reqs.includes('flask'))
115
+ framework = 'flask';
116
+ }
117
+ catch (_err) {
118
+ // Ignore
119
+ }
120
+ }
121
+ return { ...base, type: 'project', projectType: 'python', framework };
122
+ }
123
+ // Check for Rust project
124
+ if (fs.existsSync(path.join(cwd, 'Cargo.toml'))) {
125
+ return { ...base, type: 'project', projectType: 'rust' };
126
+ }
127
+ // Check for Go project
128
+ if (fs.existsSync(path.join(cwd, 'go.mod'))) {
129
+ return { ...base, type: 'project', projectType: 'go' };
130
+ }
131
+ // Check for Java/Kotlin project
132
+ if (fs.existsSync(path.join(cwd, 'pom.xml')) || fs.existsSync(path.join(cwd, 'build.gradle'))) {
133
+ return { ...base, type: 'project', projectType: 'java' };
134
+ }
135
+ // Check folder contents for type hints
136
+ try {
137
+ const files = fs.readdirSync(cwd);
138
+ const hasDocuments = files.some((f) => /\.(md|txt|doc|docx|rst)$/i.test(f));
139
+ const hasAssets = files.some((f) => /\.(png|jpg|jpeg|gif|svg|mp4|mp3|pdf|ai|psd)$/i.test(f));
140
+ if (hasAssets && !hasDocuments) {
141
+ return { ...base, type: 'assets' };
142
+ }
143
+ if (hasDocuments) {
144
+ return { ...base, type: 'documents' };
145
+ }
146
+ }
147
+ catch (_err) {
148
+ // Can't read directory
149
+ }
150
+ return base;
151
+ }
152
+ /**
153
+ * Format folder metadata for display.
154
+ * Returns a string like "TypeScript/React project" or "Python/FastAPI project"
155
+ */
156
+ function formatFolderMetadata(metadata) {
157
+ if (metadata.type === 'unknown') {
158
+ return 'folder';
159
+ }
160
+ if (metadata.type === 'documents') {
161
+ return 'documents';
162
+ }
163
+ if (metadata.type === 'assets') {
164
+ return 'assets';
165
+ }
166
+ // Project type
167
+ const parts = [];
168
+ if (metadata.projectType) {
169
+ // Capitalize first letter
170
+ parts.push(metadata.projectType.charAt(0).toUpperCase() + metadata.projectType.slice(1));
171
+ }
172
+ if (metadata.framework) {
173
+ // Capitalize first letter
174
+ parts.push(metadata.framework.charAt(0).toUpperCase() + metadata.framework.slice(1));
175
+ }
176
+ if (parts.length === 0) {
177
+ return 'project';
178
+ }
179
+ return `${parts.join('/')} project`;
180
+ }
181
+ module.exports = {
182
+ MAX_GROUP_ID_LENGTH,
183
+ normalizePathToGroupId,
184
+ getCurrentGroupId,
185
+ getHierarchicalGroupIds,
186
+ detectFolderMetadata,
187
+ formatFolderMetadata,
188
+ };
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const { MCP_ENDPOINT, ZEP_API_KEY } = require('../../config');
4
+ const { getCurrentGroupId, getHierarchicalGroupIds } = require('./group-id');
5
+ const CLIENT_INFO = { name: 'claude-hooks', version: '0.1.0' };
6
+ const PROTOCOL_VERSION = '2024-11-05';
7
+ /**
8
+ * Get headers for MCP requests.
9
+ * Adds Zep API key authentication when using Zep Cloud endpoint.
10
+ */
11
+ function getHeaders() {
12
+ const headers = {
13
+ 'Content-Type': 'application/json',
14
+ Accept: 'application/json, text/event-stream',
15
+ };
16
+ // Add Zep auth header when using Zep Cloud
17
+ if (ZEP_API_KEY && MCP_ENDPOINT.includes('getzep.com')) {
18
+ headers['Authorization'] = `Api-Key ${ZEP_API_KEY}`;
19
+ }
20
+ return headers;
21
+ }
22
+ let SESSION_ID = null;
23
+ class MCPError extends Error {
24
+ constructor(message, status) {
25
+ super(message);
26
+ this.status = status;
27
+ }
28
+ }
29
+ function extractEventStreamData(text) {
30
+ const lines = text.split('\n').map((l) => l.trim());
31
+ const dataLines = lines.filter((l) => l.startsWith('data:')).map((l) => l.replace(/^data:\s*/, ''));
32
+ if (!dataLines.length)
33
+ return null;
34
+ const candidate = dataLines.join('\n');
35
+ try {
36
+ return JSON.parse(candidate);
37
+ }
38
+ catch (_err) {
39
+ return null;
40
+ }
41
+ }
42
+ async function initialize(timeoutMs = 8000) {
43
+ const body = {
44
+ jsonrpc: '2.0',
45
+ id: 'init',
46
+ method: 'initialize',
47
+ params: {
48
+ protocolVersion: PROTOCOL_VERSION,
49
+ capabilities: {},
50
+ clientInfo: CLIENT_INFO,
51
+ },
52
+ };
53
+ const resp = await fetch(MCP_ENDPOINT, {
54
+ method: 'POST',
55
+ headers: getHeaders(),
56
+ body: JSON.stringify(body),
57
+ signal: AbortSignal.timeout(timeoutMs),
58
+ });
59
+ const session = resp.headers.get('mcp-session-id');
60
+ if (!session)
61
+ throw new MCPError('No mcp-session-id header from MCP', resp.status);
62
+ SESSION_ID = session;
63
+ return SESSION_ID;
64
+ }
65
+ async function rpcCall(method, params = {}, sessionId = null, timeoutMs = 8000) {
66
+ const sid = sessionId || SESSION_ID || (await initialize(timeoutMs));
67
+ const headers = { ...getHeaders(), 'MCP-SESSION-ID': sid };
68
+ const payload = method === 'initialize' || method === 'ping' || method.startsWith('tools/')
69
+ ? { jsonrpc: '2.0', id: '1', method, params }
70
+ : { jsonrpc: '2.0', id: '1', method: 'tools/call', params: { name: method, arguments: params } };
71
+ const resp = await fetch(MCP_ENDPOINT, {
72
+ method: 'POST',
73
+ headers,
74
+ body: JSON.stringify(payload),
75
+ signal: AbortSignal.timeout(timeoutMs),
76
+ });
77
+ const newSid = resp.headers.get('mcp-session-id');
78
+ if (newSid)
79
+ SESSION_ID = newSid;
80
+ const text = await resp.text();
81
+ let data;
82
+ try {
83
+ data = JSON.parse(text);
84
+ }
85
+ catch (_err) {
86
+ const eventParsed = extractEventStreamData(text);
87
+ if (eventParsed) {
88
+ data = eventParsed;
89
+ }
90
+ else {
91
+ const snippet = text ? text.slice(0, 200) : '<empty>';
92
+ throw new MCPError(`Invalid JSON from MCP (status ${resp.status || 'unknown'}): ${snippet}`);
93
+ }
94
+ }
95
+ if (resp.status >= 400) {
96
+ const msg = data?.error?.message || `HTTP ${resp.status}`;
97
+ throw new MCPError(msg, resp.status);
98
+ }
99
+ if (data.error)
100
+ throw new MCPError(data.error.message || 'RPC error');
101
+ const result = data.result?.structuredContent?.result || data.result || data;
102
+ return [result, SESSION_ID];
103
+ }
104
+ /**
105
+ * Add a single group ID to params (for write operations).
106
+ * Uses the current folder's group ID if not specified.
107
+ */
108
+ function withGroup(params, groupId = null) {
109
+ if (params.group_ids)
110
+ return params;
111
+ return { ...params, group_ids: [groupId || getCurrentGroupId()] };
112
+ }
113
+ /**
114
+ * Add hierarchical group IDs to params (for read operations).
115
+ * Queries current folder + all parent folders up to $HOME.
116
+ */
117
+ function withHierarchicalGroups(params) {
118
+ if (params.group_ids)
119
+ return params;
120
+ return { ...params, group_ids: getHierarchicalGroupIds() };
121
+ }
122
+ module.exports = {
123
+ rpcCall,
124
+ withGroup,
125
+ withHierarchicalGroups,
126
+ initialize,
127
+ MCP_ENDPOINT,
128
+ getCurrentGroupId,
129
+ getHierarchicalGroupIds,
130
+ MCPError,
131
+ };
@@ -0,0 +1,256 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ /**
6
+ * Parse a JSONL transcript file and extract work summary
7
+ */
8
+ function parseTranscript(transcriptPath) {
9
+ const summary = {
10
+ filesModified: new Set(),
11
+ filesCreated: new Set(),
12
+ commandsRun: [],
13
+ toolsUsed: new Map(),
14
+ assistantSummary: '',
15
+ timestamp: new Date().toISOString(),
16
+ durationMs: 0,
17
+ totalCostUSD: 0,
18
+ };
19
+ if (!fs.existsSync(transcriptPath)) {
20
+ return summary;
21
+ }
22
+ try {
23
+ const content = fs.readFileSync(transcriptPath, 'utf8');
24
+ const lines = content.split('\n').filter((line) => line.trim());
25
+ let firstTimestamp = null;
26
+ let lastTimestamp = null;
27
+ let lastAssistantMessage = '';
28
+ for (const line of lines) {
29
+ try {
30
+ const entry = JSON.parse(line);
31
+ // Skip side chains (subagent work - track separately if needed)
32
+ if (entry.isSidechain) {
33
+ continue;
34
+ }
35
+ // Track timestamps for duration calculation
36
+ if (entry.timestamp) {
37
+ const ts = new Date(entry.timestamp).getTime();
38
+ if (firstTimestamp === null)
39
+ firstTimestamp = ts;
40
+ lastTimestamp = ts;
41
+ }
42
+ // Track costs
43
+ if (entry.costUSD) {
44
+ summary.totalCostUSD += entry.costUSD;
45
+ }
46
+ // Process messages
47
+ if (entry.message) {
48
+ processMessage(entry.message, summary);
49
+ // Track last assistant message for summary
50
+ if (entry.message.role === 'assistant') {
51
+ const text = extractTextFromMessage(entry.message);
52
+ if (text) {
53
+ lastAssistantMessage = text;
54
+ }
55
+ }
56
+ }
57
+ }
58
+ catch (_parseErr) {
59
+ // Skip malformed lines, continue parsing
60
+ continue;
61
+ }
62
+ }
63
+ // Calculate duration
64
+ if (firstTimestamp !== null && lastTimestamp !== null) {
65
+ summary.durationMs = lastTimestamp - firstTimestamp;
66
+ }
67
+ // Set the assistant summary (truncate if too long)
68
+ summary.assistantSummary = truncateText(lastAssistantMessage, 500);
69
+ }
70
+ catch (_err) {
71
+ // Return empty summary on read errors
72
+ }
73
+ return summary;
74
+ }
75
+ /**
76
+ * Process a message entry and extract tool uses
77
+ */
78
+ function processMessage(message, summary) {
79
+ if (!message.content)
80
+ return;
81
+ const contents = Array.isArray(message.content)
82
+ ? message.content
83
+ : [{ type: 'text', text: message.content }];
84
+ for (const content of contents) {
85
+ if (content.type === 'tool_use' && content.name) {
86
+ // Track tool usage count
87
+ const count = summary.toolsUsed.get(content.name) || 0;
88
+ summary.toolsUsed.set(content.name, count + 1);
89
+ // Extract file operations
90
+ if (content.input) {
91
+ processToolInput(content.name, content.input, summary);
92
+ }
93
+ }
94
+ if (content.type === 'tool_result' && content.name) {
95
+ // Also track from tool results if present
96
+ const count = summary.toolsUsed.get(content.name) || 0;
97
+ if (count === 0) {
98
+ summary.toolsUsed.set(content.name, 1);
99
+ }
100
+ }
101
+ }
102
+ }
103
+ /**
104
+ * Process tool input to extract file/command information
105
+ */
106
+ function processToolInput(toolName, input, summary) {
107
+ const filePath = input.file_path;
108
+ switch (toolName) {
109
+ case 'Write':
110
+ if (filePath) {
111
+ summary.filesCreated.add(normalizePath(filePath));
112
+ }
113
+ break;
114
+ case 'Edit':
115
+ case 'MultiEdit':
116
+ if (filePath) {
117
+ summary.filesModified.add(normalizePath(filePath));
118
+ }
119
+ break;
120
+ case 'Bash':
121
+ if (input.command) {
122
+ // Track non-trivial commands (skip simple reads)
123
+ const cmd = input.command.trim();
124
+ if (!isReadOnlyCommand(cmd)) {
125
+ summary.commandsRun.push(truncateText(cmd, 200));
126
+ }
127
+ }
128
+ break;
129
+ case 'NotebookEdit':
130
+ if (filePath) {
131
+ summary.filesModified.add(normalizePath(filePath));
132
+ }
133
+ break;
134
+ }
135
+ }
136
+ /**
137
+ * Check if a command is read-only (doesn't modify state)
138
+ */
139
+ function isReadOnlyCommand(cmd) {
140
+ const readOnlyPatterns = [
141
+ /^ls\s/,
142
+ /^cat\s/,
143
+ /^head\s/,
144
+ /^tail\s/,
145
+ /^grep\s/,
146
+ /^find\s/,
147
+ /^which\s/,
148
+ /^echo\s/,
149
+ /^pwd$/,
150
+ /^git\s+status/,
151
+ /^git\s+log/,
152
+ /^git\s+diff/,
153
+ /^git\s+show/,
154
+ /^git\s+branch/,
155
+ /^npm\s+list/,
156
+ /^node\s+-[evp]/,
157
+ ];
158
+ return readOnlyPatterns.some((pattern) => pattern.test(cmd));
159
+ }
160
+ /**
161
+ * Extract text content from a message
162
+ */
163
+ function extractTextFromMessage(message) {
164
+ if (typeof message.content === 'string') {
165
+ return message.content;
166
+ }
167
+ if (Array.isArray(message.content)) {
168
+ const textParts = message.content
169
+ .filter((c) => c.type === 'text' && c.text)
170
+ .map((c) => c.text);
171
+ return textParts.join('\n');
172
+ }
173
+ return '';
174
+ }
175
+ /**
176
+ * Normalize a file path (remove leading cwd, make relative)
177
+ */
178
+ function normalizePath(filePath) {
179
+ const cwd = process.cwd();
180
+ if (filePath.startsWith(cwd)) {
181
+ return filePath.slice(cwd.length + 1);
182
+ }
183
+ // Remove leading slash for absolute paths outside cwd
184
+ if (filePath.startsWith('/')) {
185
+ return filePath;
186
+ }
187
+ return filePath;
188
+ }
189
+ /**
190
+ * Truncate text to a maximum length
191
+ */
192
+ function truncateText(text, maxLength) {
193
+ if (text.length <= maxLength)
194
+ return text;
195
+ return text.slice(0, maxLength - 3) + '...';
196
+ }
197
+ /**
198
+ * Find the most recent transcript file in a directory
199
+ */
200
+ function findMostRecentTranscript(dir) {
201
+ if (!fs.existsSync(dir)) {
202
+ return null;
203
+ }
204
+ try {
205
+ const files = fs.readdirSync(dir)
206
+ .filter((f) => f.endsWith('.jsonl'))
207
+ .map((f) => {
208
+ const fullPath = path.join(dir, f);
209
+ return {
210
+ path: fullPath,
211
+ mtime: fs.statSync(fullPath).mtime.getTime(),
212
+ };
213
+ })
214
+ .sort((a, b) => b.mtime - a.mtime);
215
+ return files.length > 0 ? files[0].path : null;
216
+ }
217
+ catch (_err) {
218
+ return null;
219
+ }
220
+ }
221
+ /**
222
+ * Get files modified during the session
223
+ */
224
+ function getFilesModified(summary) {
225
+ return summary.filesModified;
226
+ }
227
+ /**
228
+ * Get tool usage distribution
229
+ */
230
+ function getToolDistribution(summary) {
231
+ return summary.toolsUsed;
232
+ }
233
+ /**
234
+ * Format duration in human-readable format
235
+ */
236
+ function formatDuration(ms) {
237
+ if (ms < 1000)
238
+ return `${ms}ms`;
239
+ const seconds = Math.floor(ms / 1000);
240
+ if (seconds < 60)
241
+ return `${seconds}s`;
242
+ const minutes = Math.floor(seconds / 60);
243
+ const remainingSeconds = seconds % 60;
244
+ if (minutes < 60)
245
+ return `${minutes}m ${remainingSeconds}s`;
246
+ const hours = Math.floor(minutes / 60);
247
+ const remainingMinutes = minutes % 60;
248
+ return `${hours}h ${remainingMinutes}m`;
249
+ }
250
+ module.exports = {
251
+ parseTranscript,
252
+ findMostRecentTranscript,
253
+ getFilesModified,
254
+ getToolDistribution,
255
+ formatDuration,
256
+ };