Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .pi/extensions/_shared/cost-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { summarizeCosts } from "./cost-history.ts";

describe("cost-history", () => {
it("returns a stable summary shape", () => {
const summary = summarizeCosts();
expect(summary.sessions).toBeGreaterThanOrEqual(0);
expect(summary.messages).toBeGreaterThanOrEqual(0);
expect(summary.totalCost).toBeGreaterThanOrEqual(0);
expect(Array.isArray(summary.topSessions)).toBe(true);
});
});
68 changes: 68 additions & 0 deletions .pi/extensions/_shared/cost-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { discoverSessions } from "./session-history.ts";

export interface CostSummary {
sessions: number;
messages: number;
totalCost: number;
providers: Record<string, { messages: number; cost: number }>;
models: Record<string, { messages: number; cost: number }>;
tools: Record<string, { calls: number }>;
projects: Record<string, { sessions: number; cost: number }>;
daily: Array<{ date: string; cost: number; messages: number }>;
topSessions: Array<{ id: string; cost: number; project?: string; transcript?: string }>;
}

function numeric(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}

function costFromTurn(turn: any): number {
return numeric(turn.cost) || numeric(turn.usage?.cost) || numeric(turn.message?.cost) || 0;
}

export function summarizeCosts(): CostSummary {
const providers: CostSummary["providers"] = {};
const models: CostSummary["models"] = {};
const tools: CostSummary["tools"] = {};
const projects: CostSummary["projects"] = {};
const dailyMap: Record<string, { cost: number; messages: number }> = {};
const topSessions: CostSummary["topSessions"] = [];
let messages = 0;
let totalCost = 0;

for (const session of discoverSessions()) {
let sessionCost = 0;
const project = session.project || "unknown";
projects[project] ??= { sessions: 0, cost: 0 };
projects[project].sessions += 1;
for (const turn of session.turns as any[]) {
if (turn.toolName) {
tools[turn.toolName] ??= { calls: 0 };
tools[turn.toolName].calls += 1;
}
if (turn.role === "tool_result") continue;
messages += 1;
const cost = costFromTurn(turn);
sessionCost += cost;
totalCost += cost;
const day = String(turn.timestamp || session.date || "unknown").slice(0, 10);
dailyMap[day] ??= { cost: 0, messages: 0 };
dailyMap[day].cost += cost;
dailyMap[day].messages += 1;
const provider = turn.provider || turn.modelProvider || "unknown";
const model = turn.model || turn.modelName || "unknown";
providers[provider] ??= { messages: 0, cost: 0 };
providers[provider].messages += 1;
providers[provider].cost += cost;
models[model] ??= { messages: 0, cost: 0 };
models[model].messages += 1;
models[model].cost += cost;
}
projects[project].cost += sessionCost;
topSessions.push({ id: session.id, cost: sessionCost, project: session.project, transcript: session.path });
}

topSessions.sort((a, b) => b.cost - a.cost);
const daily = Object.entries(dailyMap).map(([date, v]) => ({ date, ...v })).sort((a, b) => a.date.localeCompare(b.date));
return { sessions: topSessions.length, messages, totalCost, providers, models, tools, projects, daily, topSessions: topSessions.slice(0, 10) };
}
69 changes: 69 additions & 0 deletions .pi/extensions/_shared/session-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { discoverSessions, lexicalSessionScore, parseSessionFile, searchSessions, searchSessionsWithMode, type SessionOutline } from "./session-history.ts";

describe("session-history", () => {
it("parses mixed session jsonl safely", () => {
const dir = mkdtempSync(join(tmpdir(), "lc-session-"));
const file = join(dir, "one.jsonl");
writeFileSync(file, [
JSON.stringify({ timestamp: "2026-01-01T00:00:00Z", cwd: "/repo/demo", role: "user", content: "find auth bug" }),
"not json",
JSON.stringify({ role: "assistant", message: { content: [{ text: "checked files" }] } }),
JSON.stringify({ toolName: "read", content: "large tool output" }),
].join("\n"));

const parsed = parseSessionFile(file)!;
expect(parsed.cwd).toBe("/repo/demo");
expect(parsed.project).toBe("demo");
expect(parsed.turns.map((t) => t.role)).toContain("user");
expect(parsed.turns.some((t) => t.toolName === "read")).toBe(true);
});

it("discovers sessions and ranks lexical matches", () => {
const root = mkdtempSync(join(tmpdir(), "lc-agent-"));
const sessions = join(root, "sessions", "p");
mkdirSync(sessions, { recursive: true });
const a = join(sessions, "a.jsonl");
const b = join(sessions, "b.jsonl");
writeFileSync(a, JSON.stringify({ timestamp: "2026-01-02", cwd: "/repo/a", role: "user", content: "fix payment route" }) + "\n");
writeFileSync(b, JSON.stringify({ timestamp: "2026-01-01", cwd: "/repo/b", role: "user", content: "write docs" }) + "\n");

const found = discoverSessions(root);
expect(found).toHaveLength(2);
const ranked = searchSessions("payment route", found, "/repo/a", 5);
expect(ranked[0].path).toBe(a);
expect(ranked[0].snippet.length).toBeLessThanOrEqual(300);
expect(ranked[0].mode).toBe("lexical");
});

it("semantic mode returns an explicit fallback result", async () => {
const result = await searchSessionsWithMode("anything", "semantic", [], process.cwd(), 5);
expect(result.mode).toMatch(/fallback/);
expect(result.note).toContain("fallback");
});

it("boosts user prompts, file paths, tool names, and current project matches", () => {
const strong: SessionOutline = {
id: "strong",
path: "/tmp/strong.jsonl",
cwd: "/repo/current",
project: "current",
turns: [
{ role: "user", text: "Fix src/auth/login.ts using grep for auth failure" },
{ role: "tool", toolName: "grep", text: "src/auth/login.ts: failed auth" },
],
};
const weak: SessionOutline = {
id: "weak",
path: "/tmp/weak.jsonl",
cwd: "/repo/other",
project: "other",
turns: [{ role: "assistant", text: "auth mentioned once" }],
};
expect(lexicalSessionScore("auth grep src/auth/login.ts", strong, "/repo/current"))
.toBeGreaterThan(lexicalSessionScore("auth grep src/auth/login.ts", weak, "/repo/current"));
});
});
123 changes: 123 additions & 0 deletions .pi/extensions/_shared/session-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, relative } from "node:path";

export interface SessionTurn { role: string; text: string; toolName?: string; timestamp?: string; cost?: number; provider?: string; model?: string }
export interface SessionOutline { id: string; path: string; project?: string; cwd?: string; date?: string; turns: SessionTurn[] }

export function agentDir(): string {
return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
}

function walkJsonl(dir: string, out: string[] = []): string[] {
if (!existsSync(dir)) return out;
for (const name of readdirSync(dir).sort()) {
const path = join(dir, name);
try {
const st = statSync(path);
if (st.isDirectory()) walkJsonl(path, out);
else if (name.endsWith(".jsonl")) out.push(path);
} catch {}
}
return out;
}

function eventText(obj: any): string {
const c = obj?.content ?? obj?.message?.content ?? obj?.text ?? obj?.prompt ?? "";
if (typeof c === "string") return c;
if (Array.isArray(c)) return c.map((x) => typeof x === "string" ? x : x?.text ?? "").join("\n");
return "";
}

export function parseSessionFile(path: string): SessionOutline | undefined {
try {
const turns: SessionTurn[] = [];
let cwd: string | undefined;
let project: string | undefined;
let date: string | undefined;
for (const line of readFileSync(path, "utf-8").split("\n")) {
if (!line.trim()) continue;
let obj: any;
try { obj = JSON.parse(line); } catch { continue; }
cwd ||= obj.cwd || obj.projectCwd || obj.session?.cwd;
project ||= obj.project || obj.projectName || (cwd ? cwd.split(/[\\/]/).pop() : undefined);
date ||= obj.timestamp || obj.time || obj.createdAt;
const role = obj.role || obj.message?.role || obj.type;
const toolName = obj.toolName || obj.name || obj.tool?.name;
const text = eventText(obj).replace(/\s+/g, " ").trim();
if (role || toolName || text) turns.push({
role: role || (toolName ? "tool" : "event"),
toolName,
text,
timestamp: obj.timestamp || obj.time,
cost: obj.cost ?? obj.usage?.cost ?? obj.message?.cost,
provider: obj.provider ?? obj.modelProvider ?? obj.message?.provider,
model: obj.model ?? obj.modelName ?? obj.message?.model,
});
}
return { id: relative(agentDir(), path).replace(/\.jsonl$/, ""), path, cwd, project, date, turns };
} catch { return undefined; }
}

export function discoverSessions(base = agentDir()): SessionOutline[] {
const dir = join(base, "sessions");
return walkJsonl(dir).map(parseSessionFile).filter((x): x is SessionOutline => !!x)
.sort((a, b) => (b.date || "").localeCompare(a.date || ""));
}

export function outlineText(session: SessionOutline, maxTurns = 8): string {
return session.turns.filter((t) => t.text && t.role !== "tool_result").slice(-maxTurns)
.map((t) => `${t.role}${t.toolName ? `:${t.toolName}` : ""}: ${t.text.slice(0, 300)}`).join("\n");
}

function termFrequency(text: string, term: string): number {
if (!term) return 0;
let count = 0;
let i = text.indexOf(term);
while (i !== -1) {
count += 1;
i = text.indexOf(term, i + term.length);
}
return count;
}

export function lexicalSessionScore(query: string, session: SessionOutline, cwd = process.cwd()): number {
const terms = query.toLowerCase().split(/\W+/).filter(Boolean);
if (terms.length === 0) return session.cwd === cwd ? 3 : 1;
let score = session.cwd === cwd ? 3 : 0;
const project = (session.project ?? "").toLowerCase();
const sessionCwd = (session.cwd ?? "").toLowerCase();
for (const term of terms) {
if (project.includes(term)) score += 3;
if (sessionCwd.includes(term)) score += term.includes("/") ? 4 : 2;
for (const turn of session.turns) {
const text = turn.text.toLowerCase();
const hits = Math.min(termFrequency(text, term), 5);
if (hits === 0) continue;
const roleBoost = turn.role === "user" ? 3 : turn.role === "tool" ? 1.5 : 1;
const pathBoost = /(?:^|[\s"'`])(?:\.?\.?\/)?[\w.-]+(?:\/[\w.-]+)+/.test(turn.text) ? 1.5 : 1;
const toolBoost = turn.toolName?.toLowerCase().includes(term) ? 2 : 1;
score += hits * roleBoost * pathBoost * toolBoost;
}
}
return score;
}

export function searchSessions(query: string, sessions = discoverSessions(), cwd = process.cwd(), limit = 5): Array<SessionOutline & { score: number; snippet: string; mode: string }> {
const terms = query.toLowerCase().split(/\W+/).filter(Boolean);
return sessions.map((s) => {
const score = lexicalSessionScore(query, s, cwd);
const snippet = outlineText(s, 4).slice(0, 300);
return { ...s, score, snippet, mode: "lexical" };
}).filter((s) => s.score > 0 || terms.length === 0).sort((a, b) => b.score - a.score).slice(0, limit);
}

export async function searchSessionsWithMode(query: string, mode = "lexical", sessions = discoverSessions(), cwd = process.cwd(), limit = 5): Promise<{ rows: Array<SessionOutline & { score: number; snippet: string; mode: string }>; mode: string; note?: string }> {
if (mode !== "semantic") return { rows: searchSessions(query, sessions, cwd, limit), mode: "lexical" };
try {
await import("@tobilu/qmd" as any);
return { rows: searchSessions(query, sessions, cwd, limit).map((r) => ({ ...r, mode: "semantic-fallback" })), mode: "lexical-fallback", note: "QMD is installed, but session semantic indexing is not built yet; using lexical fallback." };
} catch {
return { rows: searchSessions(query, sessions, cwd, limit), mode: "lexical-fallback", note: "Semantic session search is unavailable because @tobilu/qmd could not initialize; using lexical fallback." };
}
}
24 changes: 24 additions & 0 deletions .pi/extensions/_shared/skill-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { listSkillCatalog } from "./skill-catalog.ts";

afterEach(() => { delete process.env.LITTLE_CODER_USER_SKILLS_DIR; });

describe("skill-catalog", () => {
it("lists repo skills with descriptions and origins", () => {
const skills = listSkillCatalog();
expect(skills.some((s) => s.origin === "repo" && s.name === "bash-guidance" && s.description)).toBe(true);
});

it("includes user-level skills", () => {
const root = mkdtempSync(join(tmpdir(), "lc-user-skills-"));
const dir = join(root, "custom");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "SKILL.md"), "---\nname: custom-user-skill\ndescription: User skill.\ntype: workflow\ntoken_cost: 50\nkeywords: [custom]\n---\nBody\n");
process.env.LITTLE_CODER_USER_SKILLS_DIR = root;
const skills = listSkillCatalog();
expect(skills.some((s) => s.origin === "user" && s.name === "custom-user-skill" && s.description === "User skill.")).toBe(true);
});
});
81 changes: 81 additions & 0 deletions .pi/extensions/_shared/skill-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { parseSkillFile } from "../skill-inject/frontmatter.ts";

export interface SkillCatalogEntry {
name: string;
type: string;
origin: "repo" | "user";
sourceDir: string;
path: string;
tokenCost: number;
targetTool?: string;
description?: string;
keywords: string[];
}

function repoSkillsRoot(): string {
return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "skills");
}

function userSkillsRoot(): string {
return process.env.LITTLE_CODER_USER_SKILLS_DIR || join(homedir(), ".pi", "skills");
}

function walkMarkdown(dir: string): string[] {
if (!existsSync(dir)) return [];
const out: string[] = [];
for (const name of readdirSync(dir).sort()) {
const path = join(dir, name);
try {
if (statSync(path).isDirectory()) out.push(...walkMarkdown(path));
else if (name.endsWith(".md")) out.push(path);
} catch {}
}
return out;
}

function firstBodyLine(body: string): string | undefined {
return body.split("\n").map((line) => line.replace(/^#+\s*/, "").trim()).find(Boolean)?.slice(0, 140);
}

function inferType(sourceDir: string, fmType: unknown): string {
if (typeof fmType === "string" && fmType) return fmType;
if (sourceDir === "tools") return "tool";
if (sourceDir === "knowledge") return "knowledge";
if (sourceDir === "protocols") return "protocol";
return sourceDir || "skill";
}

export function listSkillCatalog(): SkillCatalogEntry[] {
const roots = [
{ root: repoSkillsRoot(), origin: "repo" as const },
{ root: userSkillsRoot(), origin: "user" as const },
];
const entries: SkillCatalogEntry[] = [];
for (const { root, origin } of roots) {
for (const path of walkMarkdown(root)) {
const parsed = parseSkillFile(readFileSync(path, "utf-8"));
if (!parsed?.body) continue;
const fm = parsed.frontmatter;
const rel = relative(root, path).split(/[\\/]/);
const sourceDir = origin === "user" ? "user" : (rel[0] || basename(dirname(path)));
const targetTool = typeof fm.target_tool === "string" && fm.target_tool ? fm.target_tool : undefined;
const name = (typeof fm.name === "string" && fm.name) || (typeof fm.topic === "string" && fm.topic) || targetTool || basename(path, ".md");
entries.push({
name,
type: inferType(sourceDir, fm.type),
origin,
sourceDir,
path,
tokenCost: typeof fm.token_cost === "number" ? fm.token_cost : 150,
targetTool,
description: typeof fm.description === "string" && fm.description ? fm.description : firstBodyLine(parsed.body),
keywords: Array.isArray(fm.keywords) ? (fm.keywords as string[]).map((k) => k.toLowerCase()) : [],
});
}
}
return entries.sort((a, b) => a.origin.localeCompare(b.origin) || a.sourceDir.localeCompare(b.sourceDir) || a.name.localeCompare(b.name));
}
Loading
Loading