Add lightweight session metadata extraction service

Introduce extractSessionMetadata() in a new session-metadata.ts module
that extracts only what the list view needs from JSONL files:

- messageCount: Uses shared countMessagesForLine() for exact parity
- firstPrompt: First non-system-reminder user message, truncated to 200 chars
- summary: Last type="summary" line's summary field
- firstTimestamp/lastTimestamp: For duration computation

Design goals:
- Parser parity: Uses forEachJsonlLine() and countMessagesForLine() from
  session-parser.ts, ensuring list counts always match detail-view counts
- No string building: Avoids JSON.stringify and markdown processing
- 2-3x faster than full parse: Only captures metadata, skips content
- Graceful degradation: Handles malformed lines identically to full parser

This is the Tier 3 data source for JSONL-first session discovery. When
neither the sessions-index.json nor the persistent cache has valid data,
this function extracts fresh metadata from the file.

Test coverage includes:
- Output matches parseSessionContent().length on sample fixtures
- Duration extraction from JSONL timestamps
- firstPrompt extraction skips system-reminder content
- Empty files return zero counts and empty strings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-28 00:52:41 -05:00
parent c20652924d
commit eda20a9886
2 changed files with 257 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
import { describe, it, expect } from "vitest";
import { extractSessionMetadata } from "../../src/server/services/session-metadata.js";
import { parseSessionContent } from "../../src/server/services/session-parser.js";
import fs from "fs/promises";
import path from "path";
describe("session-metadata", () => {
it("messageCount matches parseSessionContent on sample-session.jsonl", async () => {
const fixturePath = path.join(__dirname, "../fixtures/sample-session.jsonl");
const content = await fs.readFile(fixturePath, "utf-8");
const meta = extractSessionMetadata(content);
const parsed = parseSessionContent(content);
expect(meta.messageCount).toBe(parsed.length);
});
it("messageCount matches parseSessionContent on edge-cases.jsonl", async () => {
const fixturePath = path.join(__dirname, "../fixtures/edge-cases.jsonl");
const content = await fs.readFile(fixturePath, "utf-8");
const meta = extractSessionMetadata(content);
const parsed = parseSessionContent(content);
expect(meta.messageCount).toBe(parsed.length);
});
it("firstPrompt skips system-reminder messages", () => {
const content = [
JSON.stringify({
type: "user",
message: { role: "user", content: "<system-reminder>hook output</system-reminder>" },
uuid: "u-sr",
timestamp: "2025-01-01T00:00:00Z",
}),
JSON.stringify({
type: "user",
message: { role: "user", content: "What is the project structure?" },
uuid: "u-real",
timestamp: "2025-01-01T00:00:01Z",
}),
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.firstPrompt).toBe("What is the project structure?");
});
it("firstPrompt truncated to 200 chars", () => {
const longMessage = "a".repeat(300);
const content = JSON.stringify({
type: "user",
message: { role: "user", content: longMessage },
uuid: "u-long",
timestamp: "2025-01-01T00:00:00Z",
});
const meta = extractSessionMetadata(content);
expect(meta.firstPrompt).toHaveLength(200);
expect(meta.firstPrompt).toBe("a".repeat(200));
});
it("summary captures the LAST summary line", () => {
const content = [
JSON.stringify({ type: "summary", summary: "First summary", uuid: "s-1" }),
JSON.stringify({
type: "user",
message: { role: "user", content: "Hello" },
uuid: "u-1",
}),
JSON.stringify({ type: "summary", summary: "Last summary", uuid: "s-2" }),
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.summary).toBe("Last summary");
});
it("timestamps captured from first and last lines with timestamps", () => {
const content = [
JSON.stringify({
type: "user",
message: { role: "user", content: "Hello" },
uuid: "u-1",
timestamp: "2025-01-01T10:00:00Z",
}),
JSON.stringify({
type: "assistant",
message: { role: "assistant", content: "Hi" },
uuid: "a-1",
timestamp: "2025-01-01T10:05:00Z",
}),
JSON.stringify({
type: "summary",
summary: "Session done",
uuid: "s-1",
}),
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.firstTimestamp).toBe("2025-01-01T10:00:00Z");
expect(meta.lastTimestamp).toBe("2025-01-01T10:05:00Z");
});
it("empty content returns zero counts and empty strings", () => {
const meta = extractSessionMetadata("");
expect(meta.messageCount).toBe(0);
expect(meta.firstPrompt).toBe("");
expect(meta.summary).toBe("");
expect(meta.firstTimestamp).toBe("");
expect(meta.lastTimestamp).toBe("");
expect(meta.parseErrors).toBe(0);
});
it("JSONL with no user messages returns empty firstPrompt", () => {
const content = [
JSON.stringify({ type: "summary", summary: "No user", uuid: "s-1" }),
JSON.stringify({ type: "progress", data: { type: "hook" }, uuid: "p-1" }),
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.firstPrompt).toBe("");
});
it("JSONL with all system-reminder users returns empty firstPrompt", () => {
const content = [
JSON.stringify({
type: "user",
message: { role: "user", content: "<system-reminder>r1</system-reminder>" },
uuid: "u-1",
}),
JSON.stringify({
type: "user",
message: { role: "user", content: "<system-reminder>r2</system-reminder>" },
uuid: "u-2",
}),
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.firstPrompt).toBe("");
});
it("single-line JSONL: firstTimestamp equals lastTimestamp", () => {
const content = JSON.stringify({
type: "user",
message: { role: "user", content: "solo" },
uuid: "u-solo",
timestamp: "2025-06-15T12:00:00Z",
});
const meta = extractSessionMetadata(content);
expect(meta.firstTimestamp).toBe("2025-06-15T12:00:00Z");
expect(meta.lastTimestamp).toBe("2025-06-15T12:00:00Z");
});
it("reports parseErrors from malformed lines", () => {
const content = [
"broken json",
JSON.stringify({
type: "user",
message: { role: "user", content: "ok" },
uuid: "u-1",
}),
"{truncated",
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.parseErrors).toBe(2);
expect(meta.messageCount).toBe(1);
});
it("skips array user content for firstPrompt (only captures string content)", () => {
const content = [
JSON.stringify({
type: "user",
message: {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "t1", content: "result" },
],
},
uuid: "u-arr",
}),
JSON.stringify({
type: "user",
message: { role: "user", content: "Second prompt as string" },
uuid: "u-str",
}),
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.firstPrompt).toBe("Second prompt as string");
});
});