claude-session-skill 1.1.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/LICENSE +21 -0
- package/README.md +173 -0
- package/SKILL.md +65 -0
- package/dist/mcp-server.js +15483 -0
- package/dist/session.js +983 -0
- package/lib/__tests__/fixtures/history-malformed.jsonl +4 -0
- package/lib/__tests__/fixtures/history.jsonl +3 -0
- package/lib/__tests__/fixtures/names.json +1 -0
- package/lib/__tests__/fixtures/session-file.jsonl +3 -0
- package/lib/__tests__/fixtures/summaries.json +1 -0
- package/lib/__tests__/format.test.ts +223 -0
- package/lib/__tests__/indexer-io.test.ts +644 -0
- package/lib/__tests__/indexer.test.ts +176 -0
- package/lib/__tests__/search.test.ts +135 -0
- package/lib/format.ts +177 -0
- package/lib/indexer.ts +732 -0
- package/lib/search.ts +81 -0
- package/mcp-server.ts +259 -0
- package/package.json +64 -0
- package/session.ts +171 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { isTopical, shortProject, decodeProjectDir, extractConversation, isGarbageSummary } from "../indexer";
|
|
3
|
+
|
|
4
|
+
describe("isTopical", () => {
|
|
5
|
+
test("rejects short messages", () => {
|
|
6
|
+
expect(isTopical("hi")).toBe(false);
|
|
7
|
+
expect(isTopical("")).toBe(false);
|
|
8
|
+
expect(isTopical("abcd")).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("rejects slash commands", () => {
|
|
12
|
+
expect(isTopical("/session list")).toBe(false);
|
|
13
|
+
expect(isTopical("/help")).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("rejects XML-like messages", () => {
|
|
17
|
+
expect(isTopical("<system-reminder>...")).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("rejects MEMORY prefixed messages", () => {
|
|
21
|
+
expect(isTopical("[MEMORY] saved")).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("accepts normal user messages", () => {
|
|
25
|
+
expect(isTopical("Fix the login bug")).toBe(true);
|
|
26
|
+
expect(isTopical("What does this function do?")).toBe(true);
|
|
27
|
+
expect(isTopical("Hello world")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("accepts messages exactly at length threshold", () => {
|
|
31
|
+
expect(isTopical("abcde")).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("shortProject", () => {
|
|
36
|
+
const HOME = process.env.HOME!;
|
|
37
|
+
|
|
38
|
+
test("returns ~ for empty or home directory", () => {
|
|
39
|
+
expect(shortProject("")).toBe("~");
|
|
40
|
+
expect(shortProject(HOME)).toBe("~");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("strips home prefix", () => {
|
|
44
|
+
expect(shortProject(`${HOME}/projects/my-app`)).toBe("projects/my-app");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("returns full path for non-home paths", () => {
|
|
48
|
+
expect(shortProject("/var/www/app")).toBe("/var/www/app");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("decodeProjectDir", () => {
|
|
53
|
+
test("decodes standard project directory names", () => {
|
|
54
|
+
expect(decodeProjectDir("-Users-tim")).toBe("/Users/tim");
|
|
55
|
+
expect(decodeProjectDir("-home-user-projects")).toBe("/home/user/projects");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("handles leading dash removal", () => {
|
|
59
|
+
const decoded = decodeProjectDir("-Users-tim-code");
|
|
60
|
+
expect(decoded.startsWith("/")).toBe(true);
|
|
61
|
+
expect(decoded).toBe("/Users/tim/code");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("isGarbageSummary", () => {
|
|
66
|
+
test("detects empty summaries", () => {
|
|
67
|
+
expect(isGarbageSummary("")).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("detects refusal responses", () => {
|
|
71
|
+
expect(isGarbageSummary("I don't have access to the transcript")).toBe(true);
|
|
72
|
+
expect(isGarbageSummary("I don't see any conversation")).toBe(true);
|
|
73
|
+
expect(isGarbageSummary("I'm afraid I can't summarize")).toBe(true);
|
|
74
|
+
expect(isGarbageSummary("I cannot provide a summary")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("detects format echoing", () => {
|
|
78
|
+
expect(isGarbageSummary("(80 chars max) something")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("accepts valid summaries", () => {
|
|
82
|
+
expect(isGarbageSummary("- Fixed authentication bug in login flow")).toBe(false);
|
|
83
|
+
expect(isGarbageSummary("- Built session indexer with search")).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("extractConversation", () => {
|
|
88
|
+
function makeLine(type: string, role: string, content: string, isMeta = false): string {
|
|
89
|
+
return JSON.stringify({
|
|
90
|
+
type,
|
|
91
|
+
isMeta,
|
|
92
|
+
message: { role, content },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test("extracts user messages", () => {
|
|
97
|
+
const text = makeLine("user", "user", "Fix the login bug please");
|
|
98
|
+
const result = extractConversation(text);
|
|
99
|
+
expect(result).toHaveLength(1);
|
|
100
|
+
expect(result[0]).toStartWith("USER:");
|
|
101
|
+
expect(result[0]).toContain("Fix the login bug");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("extracts assistant messages", () => {
|
|
105
|
+
const text = makeLine("assistant", "assistant", "I'll fix that bug by updating the auth handler to check for expired tokens.");
|
|
106
|
+
const result = extractConversation(text);
|
|
107
|
+
expect(result).toHaveLength(1);
|
|
108
|
+
expect(result[0]).toStartWith("ASSISTANT:");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("skips meta messages", () => {
|
|
112
|
+
const text = makeLine("user", "user", "some meta content", true);
|
|
113
|
+
const result = extractConversation(text);
|
|
114
|
+
expect(result).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("skips non-topical messages", () => {
|
|
118
|
+
const text = makeLine("user", "user", "/help");
|
|
119
|
+
const result = extractConversation(text);
|
|
120
|
+
expect(result).toHaveLength(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("respects maxMessages limit by taking last N", () => {
|
|
124
|
+
const lines = Array.from({ length: 10 }, (_, i) =>
|
|
125
|
+
makeLine("user", "user", `Message number ${i} is here`)
|
|
126
|
+
).join("\n");
|
|
127
|
+
const result = extractConversation(lines, 3);
|
|
128
|
+
expect(result).toHaveLength(3);
|
|
129
|
+
// Should take the LAST 3 messages
|
|
130
|
+
expect(result[2]).toContain("Message number 9");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("handles malformed JSONL gracefully", () => {
|
|
134
|
+
const text = [
|
|
135
|
+
"{invalid json",
|
|
136
|
+
makeLine("user", "user", "Valid message here"),
|
|
137
|
+
"not json at all",
|
|
138
|
+
].join("\n");
|
|
139
|
+
const result = extractConversation(text);
|
|
140
|
+
expect(result).toHaveLength(1);
|
|
141
|
+
expect(result[0]).toContain("Valid message");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("handles empty input", () => {
|
|
145
|
+
expect(extractConversation("")).toHaveLength(0);
|
|
146
|
+
expect(extractConversation("\n\n\n")).toHaveLength(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("truncates long user messages to 300 chars", () => {
|
|
150
|
+
const longMsg = "x".repeat(500);
|
|
151
|
+
const text = makeLine("user", "user", longMsg);
|
|
152
|
+
const result = extractConversation(text);
|
|
153
|
+
expect(result[0].length).toBeLessThanOrEqual(306);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("handles assistant messages with content blocks", () => {
|
|
157
|
+
const text = JSON.stringify({
|
|
158
|
+
type: "assistant",
|
|
159
|
+
message: {
|
|
160
|
+
role: "assistant",
|
|
161
|
+
content: [
|
|
162
|
+
{ type: "text", text: "Here is a detailed explanation of how the function works and why it needs fixing." },
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
const result = extractConversation(text);
|
|
167
|
+
expect(result).toHaveLength(1);
|
|
168
|
+
expect(result[0]).toStartWith("ASSISTANT:");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("skips short assistant messages", () => {
|
|
172
|
+
const text = makeLine("assistant", "assistant", "OK");
|
|
173
|
+
const result = extractConversation(text);
|
|
174
|
+
expect(result).toHaveLength(0);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { searchSessions } from "../search";
|
|
3
|
+
import type { SessionEntry } from "../indexer";
|
|
4
|
+
|
|
5
|
+
function makeSession(overrides: Partial<SessionEntry> = {}): SessionEntry {
|
|
6
|
+
return {
|
|
7
|
+
id: "test-" + Math.random().toString(36).slice(2, 10),
|
|
8
|
+
name: "",
|
|
9
|
+
project: "",
|
|
10
|
+
projectDir: "",
|
|
11
|
+
topic: "",
|
|
12
|
+
firstMessage: "",
|
|
13
|
+
lastMessage: "",
|
|
14
|
+
allMessages: "",
|
|
15
|
+
messageCount: 1,
|
|
16
|
+
firstTimestamp: Date.now() - 86400000 * 7,
|
|
17
|
+
lastTimestamp: Date.now() - 86400000 * 7,
|
|
18
|
+
cwd: "",
|
|
19
|
+
gitBranch: "",
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("searchSessions", () => {
|
|
25
|
+
test("returns empty array for no matches", () => {
|
|
26
|
+
const sessions = [makeSession({ topic: "fix login bug" })];
|
|
27
|
+
const results = searchSessions(sessions, "deploy");
|
|
28
|
+
expect(results).toHaveLength(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("returns all sessions for empty query tokens", () => {
|
|
32
|
+
const sessions = [
|
|
33
|
+
makeSession({ topic: "fix login" }),
|
|
34
|
+
makeSession({ topic: "add feature" }),
|
|
35
|
+
];
|
|
36
|
+
const results = searchSessions(sessions, "a");
|
|
37
|
+
expect(results).toHaveLength(2);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("matches on topic", () => {
|
|
41
|
+
const sessions = [
|
|
42
|
+
makeSession({ id: "a", topic: "deploy pipeline setup" }),
|
|
43
|
+
makeSession({ id: "b", topic: "unrelated work" }),
|
|
44
|
+
];
|
|
45
|
+
const results = searchSessions(sessions, "deploy");
|
|
46
|
+
expect(results).toHaveLength(1);
|
|
47
|
+
expect(results[0].id).toBe("a");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("ranks name matches highest", () => {
|
|
51
|
+
const sessions = [
|
|
52
|
+
makeSession({ id: "a", name: "", topic: "deploy pipeline" }),
|
|
53
|
+
makeSession({ id: "b", name: "Deploy Fix", topic: "unrelated" }),
|
|
54
|
+
];
|
|
55
|
+
const results = searchSessions(sessions, "deploy");
|
|
56
|
+
expect(results[0].id).toBe("b");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("ranks topic matches above allMessages matches", () => {
|
|
60
|
+
const sessions = [
|
|
61
|
+
makeSession({ id: "a", topic: "unrelated", allMessages: "deploy fix" }),
|
|
62
|
+
makeSession({ id: "b", topic: "deploy pipeline", allMessages: "other stuff" }),
|
|
63
|
+
];
|
|
64
|
+
const results = searchSessions(sessions, "deploy");
|
|
65
|
+
expect(results[0].id).toBe("b");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("ranks firstMessage matches above lastMessage", () => {
|
|
69
|
+
const sessions = [
|
|
70
|
+
makeSession({ id: "a", firstMessage: "unrelated", lastMessage: "fixed the deploy" }),
|
|
71
|
+
makeSession({ id: "b", firstMessage: "fix the deploy", lastMessage: "unrelated" }),
|
|
72
|
+
];
|
|
73
|
+
const results = searchSessions(sessions, "deploy");
|
|
74
|
+
expect(results[0].id).toBe("b");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("supports quoted phrase matching", () => {
|
|
78
|
+
const sessions = [
|
|
79
|
+
makeSession({ id: "a", topic: "deploy fix" }),
|
|
80
|
+
makeSession({ id: "b", topic: "fix deploy issue" }),
|
|
81
|
+
];
|
|
82
|
+
const results = searchSessions(sessions, '"deploy fix"');
|
|
83
|
+
expect(results[0].id).toBe("a");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("matches on project path", () => {
|
|
87
|
+
const sessions = [
|
|
88
|
+
makeSession({ id: "a", project: "createsocial", topic: "something" }),
|
|
89
|
+
makeSession({ id: "b", project: "looksmaxx", topic: "something else" }),
|
|
90
|
+
];
|
|
91
|
+
const results = searchSessions(sessions, "createsocial");
|
|
92
|
+
expect(results).toHaveLength(1);
|
|
93
|
+
expect(results[0].id).toBe("a");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("matches on cwd", () => {
|
|
97
|
+
const sessions = [
|
|
98
|
+
makeSession({ id: "a", cwd: "/Users/tim/projects/myapp", topic: "work" }),
|
|
99
|
+
makeSession({ id: "b", cwd: "/Users/tim/other", topic: "work" }),
|
|
100
|
+
];
|
|
101
|
+
const results = searchSessions(sessions, "myapp");
|
|
102
|
+
expect(results).toHaveLength(1);
|
|
103
|
+
expect(results[0].id).toBe("a");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("applies recency boost for sessions within 1 day", () => {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
const sessions = [
|
|
109
|
+
makeSession({ id: "old", topic: "deploy", lastTimestamp: now - 86400000 * 30 }),
|
|
110
|
+
makeSession({ id: "recent", topic: "deploy", lastTimestamp: now - 3600000 }),
|
|
111
|
+
];
|
|
112
|
+
const results = searchSessions(sessions, "deploy");
|
|
113
|
+
expect(results[0].id).toBe("recent");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("handles multiple search tokens", () => {
|
|
117
|
+
const sessions = [
|
|
118
|
+
makeSession({ id: "a", topic: "fix login bug in auth" }),
|
|
119
|
+
makeSession({ id: "b", topic: "fix deploy pipeline" }),
|
|
120
|
+
makeSession({ id: "c", topic: "login page redesign" }),
|
|
121
|
+
];
|
|
122
|
+
const results = searchSessions(sessions, "fix login");
|
|
123
|
+
expect(results[0].id).toBe("a");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("returns results sorted by score then recency", () => {
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
const sessions = [
|
|
129
|
+
makeSession({ id: "a", topic: "deploy", lastTimestamp: now - 86400000 * 10 }),
|
|
130
|
+
makeSession({ id: "b", topic: "deploy", lastTimestamp: now - 86400000 * 5 }),
|
|
131
|
+
];
|
|
132
|
+
const results = searchSessions(sessions, "deploy");
|
|
133
|
+
expect(results[0].id).toBe("b");
|
|
134
|
+
});
|
|
135
|
+
});
|
package/lib/format.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { SessionEntry } from "./indexer";
|
|
2
|
+
import { isGarbageSummary } from "./indexer";
|
|
3
|
+
|
|
4
|
+
function truncate(s: string, max: number): string {
|
|
5
|
+
if (s.length <= max) return s;
|
|
6
|
+
// Use Array.from to split by code points, not UTF-16 code units.
|
|
7
|
+
// This prevents splitting surrogate pairs (emoji, CJK, etc.)
|
|
8
|
+
const chars = Array.from(s);
|
|
9
|
+
if (chars.length <= max) return s;
|
|
10
|
+
return chars.slice(0, max - 3).join("") + "...";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatDate(ts: number): string {
|
|
14
|
+
if (!ts) return "unknown";
|
|
15
|
+
const d = new Date(ts);
|
|
16
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
17
|
+
const month = months[d.getMonth()];
|
|
18
|
+
const day = d.getDate();
|
|
19
|
+
const hours = d.getHours();
|
|
20
|
+
const mins = d.getMinutes().toString().padStart(2, "0");
|
|
21
|
+
const ampm = hours >= 12 ? "PM" : "AM";
|
|
22
|
+
const h = hours % 12 || 12;
|
|
23
|
+
return `${month} ${day}, ${h}:${mins} ${ampm}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Get single-line display for list view — first bullet or last message
|
|
27
|
+
function displayLine(s: SessionEntry): string {
|
|
28
|
+
if (s.topic && !isGarbageSummary(s.topic)) {
|
|
29
|
+
// If topic has bullets, return just the first one
|
|
30
|
+
const lines = s.topic.split("\n").filter((l: string) => l.trim().length > 0);
|
|
31
|
+
const firstBullet = lines.find((l: string) => l.startsWith("- "));
|
|
32
|
+
if (firstBullet) return firstBullet;
|
|
33
|
+
// Single-line summary (old format) — use as-is
|
|
34
|
+
return lines[0] || s.topic;
|
|
35
|
+
}
|
|
36
|
+
if (s.lastMessage) return s.lastMessage;
|
|
37
|
+
return s.firstMessage || "(no messages)";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Combine name + summary for list/search views
|
|
41
|
+
function makeLabel(s: SessionEntry, summary: string): string {
|
|
42
|
+
if (!s.name) return summary;
|
|
43
|
+
const clean = summary.startsWith("- ") ? summary.slice(2) : summary;
|
|
44
|
+
return `${s.name} — ${clean}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Get full bullet summary for detail view
|
|
48
|
+
function fullSummary(s: SessionEntry): string {
|
|
49
|
+
if (s.topic && !isGarbageSummary(s.topic)) return s.topic;
|
|
50
|
+
if (s.lastMessage) return s.lastMessage;
|
|
51
|
+
return s.firstMessage || "(no messages)";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function formatSessionList(sessions: SessionEntry[], showAll: boolean): string {
|
|
55
|
+
const list = showAll ? sessions : sessions.slice(0, 20);
|
|
56
|
+
const lines: string[] = [];
|
|
57
|
+
|
|
58
|
+
if (!showAll && sessions.length > 20) {
|
|
59
|
+
lines.push(`${sessions.length} sessions (showing 20, use --all for all)\n`);
|
|
60
|
+
} else {
|
|
61
|
+
lines.push(`${list.length} session(s)\n`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const s of list) {
|
|
65
|
+
const date = formatDate(s.lastTimestamp);
|
|
66
|
+
const msgs = `${s.messageCount} msgs`;
|
|
67
|
+
const summary = displayLine(s);
|
|
68
|
+
const label = makeLabel(s, summary);
|
|
69
|
+
const lastMsg = s.lastMessage && s.lastMessage !== summary
|
|
70
|
+
? s.lastMessage
|
|
71
|
+
: "";
|
|
72
|
+
|
|
73
|
+
lines.push(`${s.id} ${msgs.padStart(7)} | ${date}`);
|
|
74
|
+
lines.push(` ${truncate(label, 100)}`);
|
|
75
|
+
if (lastMsg) {
|
|
76
|
+
lines.push(` Left off: "${truncate(lastMsg, 90)}"`);
|
|
77
|
+
}
|
|
78
|
+
lines.push("");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatSearchResults(sessions: SessionEntry[], query: string): string {
|
|
85
|
+
if (sessions.length === 0) {
|
|
86
|
+
return `No sessions found matching "${query}"`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
const shown = sessions.slice(0, 15);
|
|
91
|
+
lines.push(`${sessions.length} session(s) matching "${query}"${sessions.length > 15 ? " (showing 15)" : ""}:\n`);
|
|
92
|
+
|
|
93
|
+
for (const s of shown) {
|
|
94
|
+
const date = formatDate(s.lastTimestamp);
|
|
95
|
+
const msgs = `${s.messageCount} msgs`;
|
|
96
|
+
const summary = displayLine(s);
|
|
97
|
+
const label = makeLabel(s, summary);
|
|
98
|
+
const lastMsg = s.lastMessage && s.lastMessage !== summary
|
|
99
|
+
? s.lastMessage
|
|
100
|
+
: "";
|
|
101
|
+
|
|
102
|
+
lines.push(`${s.id} ${msgs.padStart(7)} | ${date}`);
|
|
103
|
+
lines.push(` ${truncate(label, 100)}`);
|
|
104
|
+
if (lastMsg) {
|
|
105
|
+
lines.push(` Left off: "${truncate(lastMsg, 90)}"`);
|
|
106
|
+
}
|
|
107
|
+
if (s.gitBranch) {
|
|
108
|
+
lines.push(` Branch: ${s.gitBranch}`);
|
|
109
|
+
}
|
|
110
|
+
lines.push("");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function formatSessionDetail(session: SessionEntry): string {
|
|
117
|
+
const s = session;
|
|
118
|
+
const lines: string[] = [];
|
|
119
|
+
lines.push(`${s.id}\n`);
|
|
120
|
+
if (s.name) {
|
|
121
|
+
lines.push(`Name: ${s.name}`);
|
|
122
|
+
}
|
|
123
|
+
lines.push(`Project: ${s.project}`);
|
|
124
|
+
lines.push(`CWD: ${s.cwd}`);
|
|
125
|
+
if (s.gitBranch) {
|
|
126
|
+
lines.push(`Branch: ${s.gitBranch}`);
|
|
127
|
+
}
|
|
128
|
+
lines.push(`Messages: ${s.messageCount}`);
|
|
129
|
+
lines.push(`Started: ${formatDate(s.firstTimestamp)}`);
|
|
130
|
+
lines.push(`Last: ${formatDate(s.lastTimestamp)}`);
|
|
131
|
+
lines.push("");
|
|
132
|
+
lines.push(`What was done:`);
|
|
133
|
+
lines.push(fullSummary(s));
|
|
134
|
+
lines.push("");
|
|
135
|
+
if (s.lastMessage) {
|
|
136
|
+
lines.push(`Left off: "${s.lastMessage.slice(0, 300)}"`);
|
|
137
|
+
lines.push("");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return lines.join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function formatStats(sessions: SessionEntry[]): string {
|
|
144
|
+
const byProject = new Map<string, { count: number; messages: number; lastActivity: number }>();
|
|
145
|
+
|
|
146
|
+
for (const s of sessions) {
|
|
147
|
+
const key = s.project || "~";
|
|
148
|
+
const existing = byProject.get(key);
|
|
149
|
+
if (existing) {
|
|
150
|
+
existing.count++;
|
|
151
|
+
existing.messages += s.messageCount;
|
|
152
|
+
if (s.lastTimestamp > existing.lastActivity) {
|
|
153
|
+
existing.lastActivity = s.lastTimestamp;
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
byProject.set(key, {
|
|
157
|
+
count: 1,
|
|
158
|
+
messages: s.messageCount,
|
|
159
|
+
lastActivity: s.lastTimestamp,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const sorted = Array.from(byProject.entries()).sort((a, b) => b[1].count - a[1].count);
|
|
165
|
+
|
|
166
|
+
const lines: string[] = [];
|
|
167
|
+
lines.push(`${sessions.length} sessions across ${sorted.length} projects\n`);
|
|
168
|
+
lines.push(` ${"Project".padEnd(30)} ${"Sessions".padStart(8)} ${"Messages".padStart(8)} Last Activity`);
|
|
169
|
+
lines.push(` ${"-".repeat(30)} ${"-".repeat(8)} ${"-".repeat(8)} ${"-".repeat(18)}`);
|
|
170
|
+
|
|
171
|
+
for (const [project, data] of sorted) {
|
|
172
|
+
const p = truncate(project, 30).padEnd(30);
|
|
173
|
+
lines.push(` ${p} ${data.count.toString().padStart(8)} ${data.messages.toString().padStart(8)} ${formatDate(data.lastActivity)}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|