@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.
- package/README.md +42 -0
- package/dist/cli.js +390 -0
- package/dist/lib/interfaces/IDockerClient.js +2 -0
- package/dist/lib/interfaces/IMcpClient.js +2 -0
- package/dist/lib/interfaces/IServices.js +2 -0
- package/dist/lib/interfaces/ITemplateCopier.js +2 -0
- package/dist/lib/mcp.js +35 -0
- package/dist/lib/services.js +57 -0
- package/dist/package.json +36 -0
- package/dist/templates/agents/.sample.env +12 -0
- package/dist/templates/agents/docs/STORAGE_SETUP.md +161 -0
- package/dist/templates/agents/skills/common/group-id.js +193 -0
- package/dist/templates/agents/skills/init-review/SKILL.md +119 -0
- package/dist/templates/agents/skills/init-review/scripts/ai-enrich.js +258 -0
- package/dist/templates/agents/skills/init-review/scripts/init-review.js +769 -0
- package/dist/templates/agents/skills/lisa/SKILL.md +92 -0
- package/dist/templates/agents/skills/lisa/cache/.gitkeep +0 -0
- package/dist/templates/agents/skills/lisa/scripts/storage.js +374 -0
- package/dist/templates/agents/skills/memory/SKILL.md +31 -0
- package/dist/templates/agents/skills/memory/scripts/memory.js +533 -0
- package/dist/templates/agents/skills/prompt/SKILL.md +19 -0
- package/dist/templates/agents/skills/prompt/scripts/prompt.js +184 -0
- package/dist/templates/agents/skills/tasks/SKILL.md +31 -0
- package/dist/templates/agents/skills/tasks/scripts/tasks.js +489 -0
- package/dist/templates/claude/config.js +40 -0
- package/dist/templates/claude/hooks/README.md +158 -0
- package/dist/templates/claude/hooks/common/complexity-rater.js +290 -0
- package/dist/templates/claude/hooks/common/context.js +263 -0
- package/dist/templates/claude/hooks/common/group-id.js +188 -0
- package/dist/templates/claude/hooks/common/mcp-client.js +131 -0
- package/dist/templates/claude/hooks/common/transcript-parser.js +256 -0
- package/dist/templates/claude/hooks/common/zep-client.js +175 -0
- package/dist/templates/claude/hooks/session-start.js +401 -0
- package/dist/templates/claude/hooks/session-stop-worker.js +341 -0
- package/dist/templates/claude/hooks/session-stop.js +122 -0
- package/dist/templates/claude/hooks/user-prompt-submit.js +256 -0
- package/dist/templates/claude/settings.json +46 -0
- package/dist/templates/docker/.env.lisa.example +17 -0
- package/dist/templates/docker/docker-compose.graphiti.yml +45 -0
- package/dist/templates/rules/shared/clean-architecture.md +333 -0
- package/dist/templates/rules/shared/code-quality-rules.md +469 -0
- package/dist/templates/rules/shared/git-rules.md +64 -0
- package/dist/templates/rules/shared/testing-principles.md +469 -0
- package/dist/templates/rules/typescript/coding-standards.md +751 -0
- package/dist/templates/rules/typescript/testing.md +629 -0
- package/dist/templates/rules/typescript/typescript-config-guide.md +465 -0
- package/package.json +64 -0
- 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
|
+
};
|