claude-session-skill 1.1.6 → 1.1.8

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.
@@ -47,40 +47,44 @@ describe("searchSessions", () => {
47
47
  expect(results[0].id).toBe("a");
48
48
  });
49
49
 
50
- test("ranks name matches highest", () => {
50
+ test("name match is included even without topic match", () => {
51
51
  const sessions = [
52
52
  makeSession({ id: "a", name: "", topic: "deploy pipeline" }),
53
53
  makeSession({ id: "b", name: "Deploy Fix", topic: "unrelated" }),
54
54
  ];
55
55
  const results = searchSessions(sessions, "deploy");
56
- expect(results[0].id).toBe("b");
56
+ expect(results.map((r) => r.id)).toContain("a");
57
+ expect(results.map((r) => r.id)).toContain("b");
57
58
  });
58
59
 
59
- test("ranks topic matches above allMessages matches", () => {
60
+ test("topic match is included", () => {
60
61
  const sessions = [
61
62
  makeSession({ id: "a", topic: "unrelated", allMessages: "deploy fix" }),
62
63
  makeSession({ id: "b", topic: "deploy pipeline", allMessages: "other stuff" }),
63
64
  ];
64
65
  const results = searchSessions(sessions, "deploy");
65
- expect(results[0].id).toBe("b");
66
+ expect(results.map((r) => r.id)).toContain("a");
67
+ expect(results.map((r) => r.id)).toContain("b");
66
68
  });
67
69
 
68
- test("ranks firstMessage matches above lastMessage", () => {
70
+ test("firstMessage and lastMessage matches are both included", () => {
69
71
  const sessions = [
70
72
  makeSession({ id: "a", firstMessage: "unrelated", lastMessage: "fixed the deploy" }),
71
73
  makeSession({ id: "b", firstMessage: "fix the deploy", lastMessage: "unrelated" }),
72
74
  ];
73
75
  const results = searchSessions(sessions, "deploy");
74
- expect(results[0].id).toBe("b");
76
+ expect(results.map((r) => r.id)).toContain("a");
77
+ expect(results.map((r) => r.id)).toContain("b");
75
78
  });
76
79
 
77
- test("supports quoted phrase matching", () => {
80
+ test("quoted phrase filters to exact matches only", () => {
78
81
  const sessions = [
79
82
  makeSession({ id: "a", topic: "deploy fix" }),
80
83
  makeSession({ id: "b", topic: "fix deploy issue" }),
81
84
  ];
82
85
  const results = searchSessions(sessions, '"deploy fix"');
83
- expect(results[0].id).toBe("a");
86
+ expect(results.map((r) => r.id)).toContain("a");
87
+ expect(results.map((r) => r.id)).not.toContain("b");
84
88
  });
85
89
 
86
90
  test("matches on project path", () => {
@@ -103,7 +107,7 @@ describe("searchSessions", () => {
103
107
  expect(results[0].id).toBe("a");
104
108
  });
105
109
 
106
- test("applies recency boost for sessions within 1 day", () => {
110
+ test("results are ordered most recent first", () => {
107
111
  const now = Date.now();
108
112
  const sessions = [
109
113
  makeSession({ id: "old", topic: "deploy", lastTimestamp: now - 86400000 * 30 }),
@@ -111,19 +115,27 @@ describe("searchSessions", () => {
111
115
  ];
112
116
  const results = searchSessions(sessions, "deploy");
113
117
  expect(results[0].id).toBe("recent");
118
+ expect(results[1].id).toBe("old");
114
119
  });
115
120
 
116
- test("handles multiple search tokens", () => {
121
+ test("handles multiple search tokens — all matching sessions included", () => {
122
+ const now = Date.now();
117
123
  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" }),
124
+ makeSession({ id: "a", topic: "fix login bug in auth", lastTimestamp: now - 1000 }),
125
+ makeSession({ id: "b", topic: "fix deploy pipeline", lastTimestamp: now - 2000 }),
126
+ makeSession({ id: "c", topic: "login page redesign", lastTimestamp: now - 3000 }),
121
127
  ];
122
128
  const results = searchSessions(sessions, "fix login");
129
+ // a and c both match ("fix login" shares tokens); b matches "fix"
130
+ const ids = results.map((r) => r.id);
131
+ expect(ids).toContain("a");
132
+ expect(ids).toContain("b");
133
+ expect(ids).toContain("c");
134
+ // All ordered most recent first
123
135
  expect(results[0].id).toBe("a");
124
136
  });
125
137
 
126
- test("returns results sorted by score then recency", () => {
138
+ test("returns results sorted by most recent first regardless of score", () => {
127
139
  const now = Date.now();
128
140
  const sessions = [
129
141
  makeSession({ id: "a", topic: "deploy", lastTimestamp: now - 86400000 * 10 }),
@@ -131,5 +143,6 @@ describe("searchSessions", () => {
131
143
  ];
132
144
  const results = searchSessions(sessions, "deploy");
133
145
  expect(results[0].id).toBe("b");
146
+ expect(results[1].id).toBe("a");
134
147
  });
135
148
  });
@@ -0,0 +1,311 @@
1
+ import { readFileSync } from "fs";
2
+ import { dirname, join } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ import {
6
+ CallToolRequestSchema,
7
+ ListToolsRequestSchema,
8
+ } from "@modelcontextprotocol/sdk/types.js";
9
+
10
+ import { buildIndex, nameSession, clearSessionName, resolveSession } from "./indexer";
11
+ import { searchSessions } from "./search";
12
+ import {
13
+ formatSessionList,
14
+ formatSearchResults,
15
+ formatSessionDetail,
16
+ formatStats,
17
+ makeAutoSessionName,
18
+ } from "./format";
19
+
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = dirname(__filename);
22
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
23
+
24
+ export function createServer(): Server {
25
+ const server = new Server(
26
+ { name: "claude-session", version: pkg.version },
27
+ { capabilities: { tools: {} } }
28
+ );
29
+
30
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
31
+ tools: [
32
+ {
33
+ name: "list_sessions",
34
+ description: "List recent Claude Code sessions with AI-generated summaries, sorted by last activity.",
35
+ inputSchema: {
36
+ type: "object",
37
+ properties: {
38
+ limit: {
39
+ type: "number",
40
+ description: "Max sessions to return. Defaults to all sessions.",
41
+ },
42
+ },
43
+ },
44
+ },
45
+ {
46
+ name: "search_sessions",
47
+ description: "Search Claude Code sessions by keyword, project path, topic, or quoted phrase.",
48
+ inputSchema: {
49
+ type: "object",
50
+ properties: {
51
+ query: {
52
+ type: "string",
53
+ description: "Search query. Supports quoted phrases for exact matches, e.g. \"auth bug\".",
54
+ },
55
+ },
56
+ required: ["query"],
57
+ },
58
+ },
59
+ {
60
+ name: "show_session",
61
+ description: "Show detailed information about a specific session: project, branch, timestamps, message count, and AI summary.",
62
+ inputSchema: {
63
+ type: "object",
64
+ properties: {
65
+ id: {
66
+ type: "string",
67
+ description: "Full or partial session ID (first 8+ characters are sufficient).",
68
+ },
69
+ },
70
+ required: ["id"],
71
+ },
72
+ },
73
+ {
74
+ name: "name_session",
75
+ description: "Give a session a memorable name for easy recall. Defaults to the most recent session if no ID is provided.",
76
+ inputSchema: {
77
+ type: "object",
78
+ properties: {
79
+ id: {
80
+ type: "string",
81
+ description: "Session ID or prefix. If omitted, names the most recent session.",
82
+ },
83
+ name: {
84
+ type: "string",
85
+ description: "Name to assign (max 50 characters).",
86
+ },
87
+ },
88
+ required: ["name"],
89
+ },
90
+ },
91
+ {
92
+ name: "autoname_session",
93
+ description: "Generate a session title from its summary and prefix it with the session start time as dd/mm/yy HH:MM.",
94
+ inputSchema: {
95
+ type: "object",
96
+ properties: {
97
+ id: {
98
+ type: "string",
99
+ description: "Session ID or prefix. If omitted, names the most recent session.",
100
+ },
101
+ },
102
+ },
103
+ },
104
+ {
105
+ name: "unname_session",
106
+ description: "Remove the name from a session. Defaults to the most recent session if no ID is provided.",
107
+ inputSchema: {
108
+ type: "object",
109
+ properties: {
110
+ id: {
111
+ type: "string",
112
+ description: "Session ID or prefix. If omitted, clears the name of the most recent session.",
113
+ },
114
+ },
115
+ required: ["id"],
116
+ },
117
+ },
118
+ {
119
+ name: "session_stats",
120
+ description: "Show session statistics broken down by project: session count, message count, and last activity.",
121
+ inputSchema: {
122
+ type: "object",
123
+ properties: {},
124
+ },
125
+ },
126
+ ],
127
+ }));
128
+
129
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
130
+ const { name, arguments: args } = request.params;
131
+
132
+ try {
133
+ switch (name) {
134
+ case "list_sessions": {
135
+ const sessions = await buildIndex();
136
+ const limit = typeof args?.limit === "number" ? args.limit : undefined;
137
+ const list = limit ? sessions.slice(0, limit) : sessions;
138
+ return {
139
+ content: [{ type: "text", text: formatSessionList(list, true) }],
140
+ };
141
+ }
142
+
143
+ case "search_sessions": {
144
+ const query = String(args?.query ?? "").trim();
145
+ if (!query) {
146
+ return {
147
+ content: [{ type: "text", text: "Error: query is required." }],
148
+ isError: true,
149
+ };
150
+ }
151
+ const sessions = await buildIndex();
152
+ const results = searchSessions(sessions, query);
153
+ return {
154
+ content: [{ type: "text", text: formatSearchResults(results, query) }],
155
+ };
156
+ }
157
+
158
+ case "show_session": {
159
+ const id = String(args?.id ?? "").trim();
160
+ if (!id) {
161
+ return {
162
+ content: [{ type: "text", text: "Error: id is required." }],
163
+ isError: true,
164
+ };
165
+ }
166
+ const sessions = await buildIndex();
167
+ const resolved = resolveSession(sessions, id);
168
+ if (!resolved.ok) {
169
+ return {
170
+ content: [{ type: "text", text: `Error: ${resolved.error}` }],
171
+ isError: true,
172
+ };
173
+ }
174
+ return {
175
+ content: [{ type: "text", text: formatSessionDetail(resolved.match) }],
176
+ };
177
+ }
178
+
179
+ case "name_session": {
180
+ const sessionName = String(args?.name ?? "").trim();
181
+ if (!sessionName) {
182
+ return {
183
+ content: [{ type: "text", text: "Error: name is required." }],
184
+ isError: true,
185
+ };
186
+ }
187
+
188
+ let sessionId: string;
189
+ if (args?.id) {
190
+ sessionId = String(args.id).trim();
191
+ } else {
192
+ // Default to most recent session
193
+ const sessions = await buildIndex();
194
+ if (sessions.length === 0) {
195
+ return {
196
+ content: [{ type: "text", text: "Error: No sessions found." }],
197
+ isError: true,
198
+ };
199
+ }
200
+ sessionId = sessions[0].id;
201
+ }
202
+
203
+ const result = await nameSession(sessionId, sessionName);
204
+ if (!result.ok) {
205
+ return {
206
+ content: [{ type: "text", text: `Error: ${result.error}` }],
207
+ isError: true,
208
+ };
209
+ }
210
+ return {
211
+ content: [
212
+ {
213
+ type: "text",
214
+ text: `Named session ${(result.fullId ?? "").slice(0, 8)}... → "${sessionName}"`,
215
+ },
216
+ ],
217
+ };
218
+ }
219
+
220
+ case "autoname_session": {
221
+ const sessions = await buildIndex();
222
+ if (sessions.length === 0) {
223
+ return {
224
+ content: [{ type: "text", text: "Error: No sessions found." }],
225
+ isError: true,
226
+ };
227
+ }
228
+
229
+ const sessionId = args?.id ? String(args.id).trim() : sessions[0].id;
230
+ const resolved = resolveSession(sessions, sessionId);
231
+ if (!resolved.ok) {
232
+ return {
233
+ content: [{ type: "text", text: `Error: ${resolved.error}` }],
234
+ isError: true,
235
+ };
236
+ }
237
+
238
+ const generatedName = makeAutoSessionName(resolved.match);
239
+ const result = await nameSession(resolved.match.id, generatedName);
240
+ if (!result.ok) {
241
+ return {
242
+ content: [{ type: "text", text: `Error: ${result.error}` }],
243
+ isError: true,
244
+ };
245
+ }
246
+ return {
247
+ content: [
248
+ {
249
+ type: "text",
250
+ text: `Named session ${(result.fullId ?? "").slice(0, 8)}... → "${generatedName}"`,
251
+ },
252
+ ],
253
+ };
254
+ }
255
+
256
+ case "unname_session": {
257
+ let sessionId: string;
258
+ if (args?.id) {
259
+ sessionId = String(args.id).trim();
260
+ } else {
261
+ const sessions = await buildIndex();
262
+ if (sessions.length === 0) {
263
+ return {
264
+ content: [{ type: "text", text: "Error: No sessions found." }],
265
+ isError: true,
266
+ };
267
+ }
268
+ sessionId = sessions[0].id;
269
+ }
270
+
271
+ const result = await clearSessionName(sessionId);
272
+ if (!result.ok) {
273
+ return {
274
+ content: [{ type: "text", text: `Error: ${result.error}` }],
275
+ isError: true,
276
+ };
277
+ }
278
+ return {
279
+ content: [
280
+ {
281
+ type: "text",
282
+ text: `Cleared name from session ${(result.fullId ?? "").slice(0, 8)}...`,
283
+ },
284
+ ],
285
+ };
286
+ }
287
+
288
+ case "session_stats": {
289
+ const sessions = await buildIndex();
290
+ return {
291
+ content: [{ type: "text", text: formatStats(sessions) }],
292
+ };
293
+ }
294
+
295
+ default:
296
+ return {
297
+ content: [{ type: "text", text: `Error: Unknown tool "${name}"` }],
298
+ isError: true,
299
+ };
300
+ }
301
+ } catch (err) {
302
+ const message = err instanceof Error ? err.message : String(err);
303
+ return {
304
+ content: [{ type: "text", text: `Error: ${message}` }],
305
+ isError: true,
306
+ };
307
+ }
308
+ });
309
+
310
+ return server;
311
+ }
package/lib/format.ts CHANGED
@@ -10,6 +10,10 @@ function truncate(s: string, max: number): string {
10
10
  return chars.slice(0, max - 3).join("") + "...";
11
11
  }
12
12
 
13
+ function normalizeWhitespace(value: string): string {
14
+ return value.replace(/\s+/g, " ").trim();
15
+ }
16
+
13
17
  function formatDate(ts: number): string {
14
18
  if (!ts) return "unknown";
15
19
  const d = new Date(ts);
@@ -23,8 +27,19 @@ function formatDate(ts: number): string {
23
27
  return `${month} ${day}, ${h}:${mins} ${ampm}`;
24
28
  }
25
29
 
30
+ function formatNameTimestamp(ts: number): string {
31
+ if (!ts) return "unknown";
32
+ const d = new Date(ts);
33
+ const day = d.getDate().toString().padStart(2, "0");
34
+ const month = (d.getMonth() + 1).toString().padStart(2, "0");
35
+ const year = (d.getFullYear() % 100).toString().padStart(2, "0");
36
+ const hours = d.getHours().toString().padStart(2, "0");
37
+ const minutes = d.getMinutes().toString().padStart(2, "0");
38
+ return `${day}/${month}/${year} ${hours}:${minutes}`;
39
+ }
40
+
26
41
  // Get single-line display for list view — first bullet or last message
27
- function displayLine(s: SessionEntry): string {
42
+ export function displayLine(s: SessionEntry): string {
28
43
  if (s.topic && !isGarbageSummary(s.topic)) {
29
44
  // If topic has bullets, return just the first one
30
45
  const lines = s.topic.split("\n").filter((l: string) => l.trim().length > 0);
@@ -37,6 +52,21 @@ function displayLine(s: SessionEntry): string {
37
52
  return s.firstMessage || "(no messages)";
38
53
  }
39
54
 
55
+ export function makeAutoSessionName(s: SessionEntry, maxLength = 50): string {
56
+ const prefix = formatNameTimestamp(s.firstTimestamp);
57
+ const summary = displayLine(s);
58
+ const cleanSummary = normalizeWhitespace(
59
+ summary.startsWith("- ") ? summary.slice(2) : summary
60
+ ).replace(/^["']+|["']+$/g, "");
61
+
62
+ if (!cleanSummary) return prefix;
63
+
64
+ const available = maxLength - prefix.length - 1;
65
+ if (available <= 0) return prefix.slice(0, maxLength);
66
+
67
+ return `${prefix} ${truncate(cleanSummary, available)}`;
68
+ }
69
+
40
70
  // Combine name + summary for list/search views
41
71
  function makeLabel(s: SessionEntry, summary: string): string {
42
72
  if (!s.name) return summary;
package/lib/search.ts CHANGED
@@ -74,8 +74,8 @@ export function searchSessions(
74
74
  scored.push({ session, score });
75
75
  }
76
76
 
77
- // Sort by score desc, then by lastTimestamp desc
78
- scored.sort((a, b) => b.score - a.score || b.session.lastTimestamp - a.session.lastTimestamp);
77
+ // Sort by most recent first (timestamp desc). Score only determines inclusion, not order.
78
+ scored.sort((a, b) => b.session.lastTimestamp - a.session.lastTimestamp);
79
79
 
80
80
  return scored.map((s) => s.session);
81
81
  }