Commit 2e7616134cbd
Changed files (8)
dots
pi
agent
extensions
session-history
home
common
dev
dots/pi/agent/extensions/session-history/index.ts
@@ -0,0 +1,607 @@
+/**
+ * Session History Extension for Pi
+ *
+ * Integrates pi with unified AI agent storage.
+ * Auto-detects the tool being used (pi, claude, copilot, etc.)
+ *
+ * Features:
+ * - /save-session: Triggers AI to generate and save session summary
+ * - /session-log: Shows today's session activity
+ * - Auto-logs session starts
+ *
+ * Storage locations (XDG-compliant):
+ * - Sessions: ~/.local/share/ai/sessions/YYYY-MM/*.md
+ * - Plans: ~/.local/share/ai/plans/*.md (no date prefix)
+ * - Learnings: ~/.local/share/ai/learnings/YYYY-MM/*.md
+ * - Research: ~/.local/share/ai/research/YYYY-MM/*.md
+ *
+ * Compatible with Claude Code, pi, opencode, and other AI coding tools.
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { writeFile, mkdir, appendFile, readFile } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { homedir, hostname } from "node:os";
+import * as chrono from "chrono-node";
+
+export default function (pi: ExtensionAPI) {
+ // Unified AI agent storage (XDG_DATA_HOME/ai)
+ const AI_DATA_DIR = join(homedir(), ".local", "share", "ai");
+ const SESSIONS_DIR = join(AI_DATA_DIR, "sessions");
+
+ // Track current session file across multiple saves
+ let currentSessionFile: string | null = null;
+ let currentSessionDescription: string | null = null;
+ // Track model to restore after session save
+ let modelToRestore: any | null = null;
+
+ /**
+ * Detect which tool is being used
+ */
+ function detectTool(): string {
+ // Check environment variables
+ if (process.env.CLAUDE_PROJECT_DIR || process.env.CLAUDE_AGENT_TYPE) {
+ return "claude";
+ }
+ if (process.env.PI_VERSION || process.env.PI_PROJECT_DIR) {
+ return "pi";
+ }
+ // Check if running under specific tools
+ const execPath = process.argv0 || "";
+ if (execPath.includes("copilot")) {
+ return "copilot";
+ }
+ if (execPath.includes("cursor")) {
+ return "cursor";
+ }
+ if (execPath.includes("pi")) {
+ return "pi";
+ }
+ if (execPath.includes("claude")) {
+ return "claude";
+ }
+
+ // Default to pi since this is a pi extension
+ return "pi";
+ }
+
+ function getDateInfo() {
+ const now = new Date();
+ const year = now.getFullYear();
+ const month = String(now.getMonth() + 1).padStart(2, "0");
+ const day = String(now.getDate()).padStart(2, "0");
+ const hours = String(now.getHours()).padStart(2, "0");
+ const minutes = String(now.getMinutes()).padStart(2, "0");
+ const seconds = String(now.getSeconds()).padStart(2, "0");
+
+ return {
+ yearMonth: `${year}-${month}`,
+ date: `${year}-${month}-${day}`,
+ timestamp: `${year}-${month}-${day}T${hours}:${minutes}:${seconds}+01:00`,
+ time: `${hours}:${minutes}`,
+ };
+ }
+
+ async function logSessionStart() {
+ const tool = detectTool();
+ const { yearMonth, date, timestamp } = getDateInfo();
+ const sessionDir = join(SESSIONS_DIR, yearMonth);
+ const logFile = join(sessionDir, `${date}_session-log.txt`);
+
+ await mkdir(sessionDir, { recursive: true });
+ await appendFile(logFile, `${timestamp} - Session started (${tool})\n`);
+ }
+
+ async function appendToSessionLog(message: string) {
+ const { yearMonth, date, timestamp } = getDateInfo();
+ const sessionDir = join(SESSIONS_DIR, yearMonth);
+ const logFile = join(sessionDir, `${date}_session-log.txt`);
+
+ await mkdir(sessionDir, { recursive: true });
+ await appendFile(logFile, `${timestamp} - ${message}\n`);
+ }
+
+ // Internal command to actually save the session (called by AI after generating summary)
+ pi.registerTool({
+ name: "save_session_to_history",
+ label: "Save Session to History",
+ description:
+ "Saves a session summary to ~/.local/share/ai/sessions/. Works with any AI coding tool (pi, claude, copilot, cursor). Auto-detects which tool is being used. If called multiple times in the same session, updates the existing file.",
+ parameters: {
+ type: "object",
+ properties: {
+ description: {
+ type: "string",
+ description: "Brief description for the filename (e.g., 'added-hostname-extension')",
+ },
+ content: {
+ type: "string",
+ description: "Full markdown content following the Session Entry template from history-system.md",
+ },
+ },
+ required: ["description", "content"],
+ },
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
+ // Helper to restore model after save
+ const restoreModel = async () => {
+ if (modelToRestore) {
+ await pi.setModel(modelToRestore);
+ ctx.ui?.notify?.(`Restored model: ${modelToRestore.name || modelToRestore.id}`, "info");
+ modelToRestore = null;
+ }
+ };
+
+ try {
+ const { description, content } = params;
+ const { yearMonth, date, time } = getDateInfo();
+ const sessionDir = join(SESSIONS_DIR, yearMonth);
+
+ // Sanitize filename
+ const slug = description
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .substring(0, 60);
+
+ const filename = `${date}-${slug}.md`;
+ const filepath = join(sessionDir, filename);
+
+ // Create directory
+ await mkdir(sessionDir, { recursive: true });
+
+ // Check if we're updating the current session or creating a new one
+ const isUpdate = currentSessionFile === filepath;
+
+ if (isUpdate) {
+ // Read existing content
+ const existingContent = await readFile(filepath, "utf-8");
+
+ // Check if we should append or replace
+ // If content has changed significantly, replace; otherwise append
+ if (content.length > existingContent.length * 1.2 || content.includes("## Update")) {
+ // Replace with new version
+ await writeFile(filepath, content, "utf-8");
+ await appendToSessionLog(`Session updated: ${filename}`);
+ await restoreModel();
+
+ return {
+ content: [
+ {
+ type: "text",
+ text: `โ Session updated: ${filepath}`,
+ },
+ ],
+ details: { updated: true },
+ };
+ } else {
+ // Append update section
+ const updateSection = `\n\n---\n\n## Update (${time})\n\n${content
+ .split("\n")
+ .filter((line) => !line.startsWith("#") && !line.startsWith("**Date:**") && !line.startsWith("**Time:**"))
+ .join("\n")}`;
+
+ await appendFile(filepath, updateSection);
+ await appendToSessionLog(`Session updated: ${filename}`);
+ await restoreModel();
+
+ return {
+ content: [
+ {
+ type: "text",
+ text: `โ Session updated (appended): ${filepath}`,
+ },
+ ],
+ details: { updated: true, appended: true },
+ };
+ }
+ } else {
+ // New session file
+ await writeFile(filepath, content, "utf-8");
+
+ // Track this as the current session
+ currentSessionFile = filepath;
+ currentSessionDescription = description;
+
+ // Append to session log
+ await appendToSessionLog(`Session saved: ${filename}`);
+ await restoreModel();
+
+ return {
+ content: [
+ {
+ type: "text",
+ text: `โ Session saved to: ${filepath}`,
+ },
+ ],
+ details: { created: true },
+ };
+ }
+ } catch (error) {
+ await restoreModel();
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Error saving session: ${error}`,
+ },
+ ],
+ details: { error: String(error) },
+ };
+ }
+ },
+ });
+
+ // Log session start and reset session tracking
+ pi.on("session_start", async (_event, ctx) => {
+ try {
+ // Reset session tracking on new session
+ currentSessionFile = null;
+ currentSessionDescription = null;
+
+ await logSessionStart();
+ // Show hint
+ ctx.ui.setStatus("session", ctx.ui.theme.fg("dim", "๐พ /save-session"));
+ } catch (error) {
+ // Silent failure
+ }
+ });
+
+ // Try to find a lighter model for session summaries
+ async function findLightModel(ctx: any): Promise<any | null> {
+ const lightModels = [
+ // Prefer cheaper/faster models for summarization
+ { provider: "google", id: "gemini-2.5-pro" },
+ { provider: "google", id: "gemini-2.0-flash" },
+ { provider: "google", id: "gemini-1.5-flash" },
+ { provider: "github-copilot", id: "gpt-4o" },
+ { provider: "github-copilot", id: "gpt-4" },
+ { provider: "anthropic", id: "claude-3-5-haiku" },
+ { provider: "anthropic", id: "claude-haiku-3" },
+ ];
+
+ for (const { provider, id } of lightModels) {
+ const model = ctx.modelRegistry.find(provider, id);
+ if (model) {
+ return model;
+ }
+ }
+ return null;
+ }
+
+ // Register /save-session command
+ pi.registerCommand("save-session", {
+ description: "Generate and save a session summary to history (auto-detects tool)",
+ handler: async (_args, ctx) => {
+ const tool = detectTool();
+ const { date, time } = getDateInfo();
+ const host = hostname();
+
+ // Try to use a lighter model for summarization
+ const currentModel = ctx.model;
+ const lightModel = await findLightModel(ctx);
+
+ if (lightModel && lightModel.id !== currentModel?.id) {
+ const success = await pi.setModel(lightModel);
+ if (success) {
+ // Store current model to restore after save
+ modelToRestore = currentModel;
+ ctx.ui.notify(`Using ${lightModel.name || lightModel.id} for summary`, "info");
+ }
+ }
+
+ // Build the prompt for the AI
+ const prompt = `Please generate ${currentSessionFile ? "an updated" : "a"} session summary for this conversation.
+
+${
+ currentSessionFile
+ ? `This session has already been saved. Please review what additional work was done and either:
+1. Generate a complete updated summary (if significant new work was done)
+2. Or generate just an update section with the new accomplishments
+
+Current session: ${currentSessionDescription}`
+ : ""
+}
+
+The summary should include:
+- A descriptive title
+- What was accomplished
+- Files that were changed
+- Commands that were run
+- The outcome
+- Any next steps
+
+Use this template format:
+
+\`\`\`markdown
+# Session: <Description>
+
+**Date:** ${date}
+**Time:** ${time}
+**Host:** ${host}
+**Tool:** ${tool}
+
+## Summary
+Brief description of what was accomplished.
+
+## What Was Accomplished
+- Task 1
+- Task 2
+
+## Files Changed
+- \`path/to/file\` - Description of change
+
+## Commands Run
+\`\`\`bash
+# Key commands executed
+\`\`\`
+
+## Outcome
+Result of the session.
+
+## Next Steps
+- [ ] TODO 1
+- [ ] TODO 2
+
+### Tags
+#${tool} #relevant-tags
+\`\`\`
+
+After generating the summary, use the save_session_to_history tool to save it${currentSessionFile ? " (it will automatically update the existing session file)" : " with an appropriate filename description"}.`;
+
+ // Send the prompt automatically (triggers AI response)
+ pi.sendUserMessage(prompt);
+ },
+ });
+
+ // Parse natural language date expressions using chrono-node
+ function parseDateSpec(spec: string): { start: Date; end: Date; label: string } | null {
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const normalized = spec.toLowerCase().trim();
+
+ // Check for date range: YYYY-MM-DD..YYYY-MM-DD (not handled by chrono)
+ const rangeMatch = spec.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/);
+ if (rangeMatch) {
+ return {
+ start: new Date(rangeMatch[1]),
+ end: new Date(rangeMatch[2]),
+ label: `${rangeMatch[1]} to ${rangeMatch[2]}`,
+ };
+ }
+
+ // Special handling for range expressions
+ if (normalized.includes(" to ") || normalized.includes(" through ") || normalized.includes(" until ")) {
+ const results = chrono.parse(spec, now);
+ if (results.length >= 2) {
+ return {
+ start: results[0].start.date(),
+ end: results[1].start.date(),
+ label: spec,
+ };
+ }
+ // Single result with end date
+ if (results.length === 1 && results[0].end) {
+ return {
+ start: results[0].start.date(),
+ end: results[0].end.date(),
+ label: spec,
+ };
+ }
+ }
+
+ // Use chrono to parse the date expression
+ const results = chrono.parse(spec, now);
+
+ if (results.length > 0) {
+ const result = results[0];
+ const start = result.start.date();
+ // If chrono found an end date (e.g., "Sep 12-13"), use it
+ const end = result.end ? result.end.date() : start;
+
+ // Normalize to start of day
+ const startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate());
+ const endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate());
+
+ return {
+ start: startDay,
+ end: endDay,
+ label: spec,
+ };
+ }
+
+ return null;
+ }
+
+ // Format date as YYYY-MM-DD (local time, not UTC)
+ function formatDate(d: Date): string {
+ const year = d.getFullYear();
+ const month = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ }
+
+ // Get all dates in range
+ function getDatesInRange(start: Date, end: Date): string[] {
+ const dates: string[] = [];
+ const current = new Date(start);
+ while (current <= end) {
+ dates.push(formatDate(current));
+ current.setDate(current.getDate() + 1);
+ }
+ return dates;
+ }
+
+ // Register /list-sessions command
+ pi.registerCommand("list-sessions", {
+ description: "List sessions. Usage: /list-sessions [today|yesterday|last week|last N days|YYYY-MM-DD|YYYY-MM-DD..YYYY-MM-DD|search <query>]",
+ handler: async (args, ctx) => {
+ const theme = ctx.ui.theme;
+ const argStr = (args || "").trim();
+
+ // Check for search mode
+ if (argStr.toLowerCase().startsWith("search ")) {
+ const query = argStr.slice(7).trim();
+ if (!query) {
+ ctx.ui.notify("Usage: /list-sessions search <query>", "error");
+ return;
+ }
+
+ try {
+ const { execSync } = await import("node:child_process");
+ // Use ripgrep to search sessions
+ const rgResult = execSync(
+ `rg -l -i "${query.replace(/"/g, '\\"')}" "${SESSIONS_DIR}" 2>/dev/null || true`,
+ { encoding: "utf-8", maxBuffer: 1024 * 1024 }
+ ).trim();
+
+ if (!rgResult) {
+ ctx.ui.notify(`No sessions found matching "${query}"`, "info");
+ return;
+ }
+
+ const files = rgResult.split("\n").filter(Boolean).slice(0, 20); // Limit to 20 results
+
+ const header = theme.bold(`๐ Sessions matching "${query}"`);
+ const separator = theme.fg("dim", "โ".repeat(50));
+
+ const fileLines = files.map((filepath) => {
+ const filename = filepath.split("/").pop() || "";
+ const date = filename.slice(0, 10);
+ const desc = filename.slice(11).replace(".md", "").replace(/-/g, " ");
+ return ` ${theme.fg("accent", "โข")} ${theme.fg("dim", date)} ${desc}\n ${theme.fg("dim", filepath)}`;
+ });
+
+ const widgetLines = [header, separator, ...fileLines];
+ if (files.length === 20) {
+ widgetLines.push(theme.fg("warning", " (showing first 20 results)"));
+ }
+ ctx.ui.setWidget("list-sessions", widgetLines);
+
+ setTimeout(() => ctx.ui.setWidget("list-sessions", undefined), 20000);
+ } catch (error) {
+ ctx.ui.notify(`Search error: ${error}`, "error");
+ }
+ return;
+ }
+
+ // Parse date spec (default to today)
+ const dateSpec = parseDateSpec(argStr || "today");
+ if (!dateSpec) {
+ ctx.ui.notify(
+ "Invalid date. Use: today, yesterday, last week, last N days, YYYY-MM-DD, or YYYY-MM-DD..YYYY-MM-DD",
+ "error"
+ );
+ return;
+ }
+
+ const { start, end, label } = dateSpec;
+ const dates = getDatesInRange(start, end);
+
+ try {
+ const { readdir } = await import("node:fs/promises");
+ const allFiles: { date: string; file: string; path: string }[] = [];
+
+ // Get unique year-months to check
+ const yearMonths = [...new Set(dates.map((d) => d.slice(0, 7)))];
+
+ for (const ym of yearMonths) {
+ const sessionDir = join(SESSIONS_DIR, ym);
+ if (!existsSync(sessionDir)) continue;
+
+ const files = await readdir(sessionDir);
+ for (const f of files) {
+ if (!f.endsWith(".md")) continue;
+ const fileDate = f.slice(0, 10);
+ if (dates.includes(fileDate)) {
+ allFiles.push({
+ date: fileDate,
+ file: f,
+ path: join(sessionDir, f),
+ });
+ }
+ }
+ }
+
+ allFiles.sort((a, b) => b.file.localeCompare(a.file)); // Newest first
+
+ if (allFiles.length === 0) {
+ ctx.ui.notify(`No sessions found for ${label}`, "info");
+ return;
+ }
+
+ const header = theme.bold(`๐ Sessions - ${label} (${allFiles.length})`);
+ const separator = theme.fg("dim", "โ".repeat(50));
+
+ const fileLines = allFiles.slice(0, 30).map(({ date, file, path }) => {
+ const desc = file.slice(11).replace(".md", "").replace(/-/g, " ");
+ return ` ${theme.fg("accent", "โข")} ${theme.fg("dim", date)} ${desc}\n ${theme.fg("dim", path)}`;
+ });
+
+ const widgetLines = [header, separator, ...fileLines];
+ if (allFiles.length > 30) {
+ widgetLines.push(theme.fg("warning", ` (showing 30 of ${allFiles.length})`));
+ }
+ ctx.ui.setWidget("list-sessions", widgetLines);
+
+ setTimeout(() => ctx.ui.setWidget("list-sessions", undefined), 20000);
+ } catch (error) {
+ ctx.ui.notify(`Error listing sessions: ${error}`, "error");
+ }
+ },
+ });
+
+ // Register /session-log command
+ pi.registerCommand("session-log", {
+ description: "View today's session log",
+ handler: async (_args, ctx) => {
+ const { yearMonth, date } = getDateInfo();
+ const logFile = join(SESSIONS_DIR, yearMonth, `${date}_session-log.txt`);
+
+ if (!existsSync(logFile)) {
+ ctx.ui.notify("No session log for today", "info");
+ return;
+ }
+
+ try {
+ const content = await readFile(logFile, "utf-8");
+ const lines = content.trim().split("\n");
+ const theme = ctx.ui.theme;
+
+ // Format each log entry with colors
+ const formattedLines = lines.map((line) => {
+ // Parse: YYYY-MM-DDTHH:MM:SS+TZ - Message
+ const match = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2})\s*-\s*(.+)$/);
+ if (match) {
+ const [, timestamp, message] = match;
+ // Extract just the time part for display
+ const time = timestamp.slice(11, 16);
+ // Color based on message type
+ let styledMessage = message;
+ if (message.includes("started")) {
+ styledMessage = theme.fg("accent", message);
+ } else if (message.includes("saved")) {
+ styledMessage = theme.fg("success", message);
+ } else if (message.includes("updated")) {
+ styledMessage = theme.fg("warning", message);
+ }
+ return `${theme.fg("dim", time)} ${styledMessage}`;
+ }
+ return theme.fg("dim", line);
+ });
+
+ // Display as widget
+ const header = theme.bold(`๐ Session Log - ${date}`);
+ const widgetLines = [header, theme.fg("dim", "โ".repeat(40)), ...formattedLines];
+ ctx.ui.setWidget("session-log", widgetLines);
+
+ // Auto-dismiss after 10 seconds
+ setTimeout(() => {
+ ctx.ui.setWidget("session-log", undefined);
+ }, 10000);
+ } catch (error) {
+ ctx.ui.notify(`Error: ${error}`, "error");
+ }
+ },
+ });
+}
dots/pi/agent/extensions/session-history/package-lock.json
@@ -0,0 +1,24 @@
+{
+ "name": "session-history",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "session-history",
+ "version": "1.0.0",
+ "dependencies": {
+ "chrono-node": "^2.9.0"
+ }
+ },
+ "node_modules/chrono-node": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.9.0.tgz",
+ "integrity": "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ }
+ }
+}
dots/pi/agent/extensions/session-history/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "session-history",
+ "version": "1.0.0",
+ "description": "Session history management for pi with unified AI agent storage",
+ "type": "module",
+ "dependencies": {
+ "chrono-node": "^2.9.0"
+ },
+ "pi": {
+ "extensions": ["./index.ts"]
+ }
+}
dots/pi/agent/extensions/session-history.ts
@@ -1,320 +0,0 @@
-/**
- * Session History Extension for Pi
- *
- * Integrates pi with Claude Code's history system.
- * Auto-detects the tool being used (pi, claude, copilot, etc.)
- *
- * Features:
- * - /save-session: Triggers AI to generate and save session summary
- * - /session-log: Shows today's session activity
- * - Auto-logs session starts
- *
- * Compatible with Claude Code's hook system and history structure.
- */
-
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { writeFile, mkdir, appendFile, readFile } from "node:fs/promises";
-import { existsSync } from "node:fs";
-import { join } from "node:path";
-import { homedir, hostname } from "node:os";
-
-export default function (pi: ExtensionAPI) {
- const HISTORY_DIR = join(homedir(), ".config", "claude", "history");
- const SESSIONS_DIR = join(HISTORY_DIR, "sessions");
-
- // Track current session file across multiple saves
- let currentSessionFile: string | null = null;
- let currentSessionDescription: string | null = null;
-
- /**
- * Detect which tool is being used
- */
- function detectTool(): string {
- // Check environment variables
- if (process.env.CLAUDE_PROJECT_DIR || process.env.CLAUDE_AGENT_TYPE) {
- return "claude";
- }
- if (process.env.PI_VERSION || process.env.PI_PROJECT_DIR) {
- return "pi";
- }
- // Check if running under specific tools
- const execPath = process.argv0 || "";
- if (execPath.includes("copilot")) {
- return "copilot";
- }
- if (execPath.includes("cursor")) {
- return "cursor";
- }
- if (execPath.includes("pi")) {
- return "pi";
- }
- if (execPath.includes("claude")) {
- return "claude";
- }
-
- // Default to pi since this is a pi extension
- return "pi";
- }
-
- function getDateInfo() {
- const now = new Date();
- const year = now.getFullYear();
- const month = String(now.getMonth() + 1).padStart(2, "0");
- const day = String(now.getDate()).padStart(2, "0");
- const hours = String(now.getHours()).padStart(2, "0");
- const minutes = String(now.getMinutes()).padStart(2, "0");
- const seconds = String(now.getSeconds()).padStart(2, "0");
-
- return {
- yearMonth: `${year}-${month}`,
- date: `${year}-${month}-${day}`,
- timestamp: `${year}-${month}-${day}T${hours}:${minutes}:${seconds}+01:00`,
- time: `${hours}:${minutes}`,
- };
- }
-
- async function logSessionStart() {
- const tool = detectTool();
- const { yearMonth, date, timestamp } = getDateInfo();
- const sessionDir = join(SESSIONS_DIR, yearMonth);
- const logFile = join(sessionDir, `${date}_session-log.txt`);
-
- await mkdir(sessionDir, { recursive: true });
- await appendFile(logFile, `${timestamp} - Session started (${tool})\n`);
- }
-
- async function appendToSessionLog(message: string) {
- const { yearMonth, date, timestamp } = getDateInfo();
- const sessionDir = join(SESSIONS_DIR, yearMonth);
- const logFile = join(sessionDir, `${date}_session-log.txt`);
-
- await mkdir(sessionDir, { recursive: true });
- await appendFile(logFile, `${timestamp} - ${message}\n`);
- }
-
- // Internal command to actually save the session (called by AI after generating summary)
- pi.registerTool({
- name: "save_session_to_history",
- label: "Save Session to History",
- description:
- "Saves a session summary to ~/.config/claude/history/sessions/. Works with any AI coding tool (pi, claude, copilot, cursor). Auto-detects which tool is being used. If called multiple times in the same session, updates the existing file.",
- parameters: {
- type: "object",
- properties: {
- description: {
- type: "string",
- description: "Brief description for the filename (e.g., 'added-hostname-extension')",
- },
- content: {
- type: "string",
- description: "Full markdown content following the Session Entry template from history-system.md",
- },
- },
- required: ["description", "content"],
- },
- async execute(toolCallId, params, signal, onUpdate, ctx) {
- try {
- const { description, content } = params;
- const { yearMonth, date, time } = getDateInfo();
- const sessionDir = join(SESSIONS_DIR, yearMonth);
-
- // Sanitize filename
- const slug = description
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-+|-+$/g, "")
- .substring(0, 60);
-
- const filename = `${date}-${slug}.md`;
- const filepath = join(sessionDir, filename);
-
- // Create directory
- await mkdir(sessionDir, { recursive: true });
-
- // Check if we're updating the current session or creating a new one
- const isUpdate = currentSessionFile === filepath;
-
- if (isUpdate) {
- // Read existing content
- const existingContent = await readFile(filepath, "utf-8");
-
- // Check if we should append or replace
- // If content has changed significantly, replace; otherwise append
- if (content.length > existingContent.length * 1.2 || content.includes("## Update")) {
- // Replace with new version
- await writeFile(filepath, content, "utf-8");
- await appendToSessionLog(`Session updated: ${filename}`);
-
- return {
- content: [
- {
- type: "text",
- text: `โ Session updated: ${filepath}`,
- },
- ],
- details: { updated: true },
- };
- } else {
- // Append update section
- const updateSection = `\n\n---\n\n## Update (${time})\n\n${content
- .split("\n")
- .filter((line) => !line.startsWith("#") && !line.startsWith("**Date:**") && !line.startsWith("**Time:**"))
- .join("\n")}`;
-
- await appendFile(filepath, updateSection);
- await appendToSessionLog(`Session updated: ${filename}`);
-
- return {
- content: [
- {
- type: "text",
- text: `โ Session updated (appended): ${filepath}`,
- },
- ],
- details: { updated: true, appended: true },
- };
- }
- } else {
- // New session file
- await writeFile(filepath, content, "utf-8");
-
- // Track this as the current session
- currentSessionFile = filepath;
- currentSessionDescription = description;
-
- // Append to session log
- await appendToSessionLog(`Session saved: ${filename}`);
-
- return {
- content: [
- {
- type: "text",
- text: `โ Session saved to: ${filepath}`,
- },
- ],
- details: { created: true },
- };
- }
- } catch (error) {
- return {
- content: [
- {
- type: "text",
- text: `Error saving session: ${error}`,
- },
- ],
- details: { error: String(error) },
- };
- }
- },
- });
-
- // Log session start and reset session tracking
- pi.on("session_start", async (_event, ctx) => {
- try {
- // Reset session tracking on new session
- currentSessionFile = null;
- currentSessionDescription = null;
-
- await logSessionStart();
- // Show hint
- ctx.ui.setStatus("session", ctx.ui.theme.fg("dim", "๐พ /save-session"));
- } catch (error) {
- // Silent failure
- }
- });
-
- // Register /save-session command
- pi.registerCommand("save-session", {
- description: "Generate and save a session summary to history (auto-detects tool)",
- handler: async (_args, ctx) => {
- const tool = detectTool();
-
- // This triggers a message that prompts the AI to generate a summary
- ctx.ui.notify("Generating session summary...", "info");
-
- // Insert a prompt for the AI
- const prompt = `Please generate ${currentSessionFile ? "an updated" : "a"} session summary for this conversation following the Session Entry template from the CORE skill's history-system.md.
-
-${
- currentSessionFile
- ? `This session has already been saved. Please review what additional work was done and either:
-1. Generate a complete updated summary (if significant new work was done)
-2. Or generate just an update section with the new accomplishments
-
-Current session: ${currentSessionDescription}`
- : ""
-}
-
-The summary should include:
-- A descriptive title
-- What was accomplished
-- Files that were changed
-- Commands that were run
-- The outcome
-- Any next steps
-
-Use the Session Entry template format:
-
-\`\`\`markdown
-# Session: <Description>
-
-**Date:** ${getDateInfo().date}
-**Time:** ${getDateInfo().time}
-**Host:** ${hostname()}
-**Tool:** ${tool}
-
-## Summary
-Brief description of what was accomplished.
-
-## What Was Accomplished
-- Task 1
-- Task 2
-
-## Files Changed
-- \`path/to/file\` - Description of change
-
-## Commands Run
-\`\`\`bash
-# Key commands executed
-\`\`\`
-
-## Outcome
-Result of the session.
-
-## Next Steps
-- [ ] TODO 1
-- [ ] TODO 2
-
-### Tags
-#${tool} #relevant-tags
-\`\`\`
-
-After generating the summary, use the save_session_to_history tool to save it${currentSessionFile ? " (it will automatically update the existing session file)" : " with an appropriate filename description"}.`;
-
- // Set the editor text to trigger the AI
- ctx.ui.setEditorText(prompt);
- },
- });
-
- // Register /session-log command
- pi.registerCommand("session-log", {
- description: "View today's session log",
- handler: async (_args, ctx) => {
- const { yearMonth, date } = getDateInfo();
- const logFile = join(SESSIONS_DIR, yearMonth, `${date}_session-log.txt`);
-
- if (!existsSync(logFile)) {
- ctx.ui.notify("No session log for today", "info");
- return;
- }
-
- try {
- const content = await readFile(logFile, "utf-8");
- console.log("\n๐ Session log:\n" + content);
- } catch (error) {
- ctx.ui.notify(`Error: ${error}`, "error");
- }
- },
- });
-}
dots/Makefile
@@ -54,13 +54,14 @@ lazypr : ~/.config/lazypr/config.toml
all += gh-news
gh-news : ~/.config/gh-news/config.toml
-all += git-template copilot-hooks opencode-plugin pi-agent agent-skills agent-skill-manager-bin
+all += git-template copilot-hooks opencode-plugin pi-agent agent-skills agent-skill-manager-bin ai-config
git-template : ~/.config/git/template
copilot-hooks : ~/.config/copilot-hooks
opencode-plugin : ~/.config/opencode/plugin
pi-agent : ~/.pi/agent/extensions ~/.pi/agent/AGENTS.md ~/.pi/agent/README.md
agent-skills : ~/.config/agent-skills
agent-skill-manager-bin : ~/bin/agent-skill-manager
+ai-config : ~/.config/ai/skills
# Agent skill manager tool
~/bin/agent-skill-manager : $(dotfiles)/config/agent-skills/agent-skill-manager force
@@ -92,6 +93,13 @@ agent-skill-manager-bin : ~/bin/agent-skill-manager
@mkdir -p ~/.pi/agent
@ln -snf $(dotfiles)/pi/agent/README.md ~/.pi/agent/README.md
+# Unified AI agent config - symlink skills to claude skills (shared)
+# Later can move to dots/config/ai/skills/ as canonical location
+~/.config/ai/skills : force
+ @echo "๐ Symlinking ~/.config/ai/skills -> ~/.config/claude/skills (shared)"
+ @mkdir -p ~/.config/ai
+ @ln -snf ~/.config/claude/skills ~/.config/ai/skills
+
# Generate ntfy client.yml from template with passage secrets injected
~/.config/ntfy/client.yml : $(dotfiles)/config/ntfy/client.yml.in $(dotfiles)/config/ntfy/ntfy-update-config force
@echo "โ๏ธ Generating $$@ from template with passage secrets"
home/common/dev/ai.nix
@@ -6,14 +6,46 @@
}:
let
claudeSyncDir = "${config.xdg.dataHome}/claude-sync";
+ # Unified AI agent storage (XDG-compliant)
+ # Physical storage in ai-sync (syncthing folder), symlinked to ai/
+ aiSyncDir = "${config.xdg.dataHome}/ai-sync";
in
{
- # Ensure claude-sync directory structure exists
+ # Ensure claude-sync directory structure exists (legacy, still used by claude)
xdg.dataFile = {
"claude-sync/history/.keep".text = "";
"claude-sync/projects/.keep".text = "";
"claude-sync/todos/.keep".text = "";
"claude-sync/plans/.keep".text = "";
+
+ # Unified AI agent data storage (syncthing folder)
+ # Sessions: markdown summaries from all AI tools
+ "ai-sync/sessions/.keep".text = "";
+ # Plans: implementation plans (topic-based, no dates)
+ "ai-sync/plans/.keep".text = "";
+ # Learnings: problem-solving narratives
+ "ai-sync/learnings/.keep".text = "";
+ # Research: research documents
+ "ai-sync/research/.keep".text = "";
+
+ # Unified AI agent data - symlink to synced location
+ # All AI tools (claude, pi, opencode) write to ~/.local/share/ai/
+ "ai/sessions" = {
+ source = config.lib.file.mkOutOfStoreSymlink "${aiSyncDir}/sessions";
+ force = true;
+ };
+ "ai/plans" = {
+ source = config.lib.file.mkOutOfStoreSymlink "${aiSyncDir}/plans";
+ force = true;
+ };
+ "ai/learnings" = {
+ source = config.lib.file.mkOutOfStoreSymlink "${aiSyncDir}/learnings";
+ force = true;
+ };
+ "ai/research" = {
+ source = config.lib.file.mkOutOfStoreSymlink "${aiSyncDir}/research";
+ force = true;
+ };
};
# Symlink claude directories to synced location
@@ -35,6 +67,10 @@ in
source = config.lib.file.mkOutOfStoreSymlink "${claudeSyncDir}/plans";
force = true;
};
+
+ # Unified AI agent config
+ # Skills will be symlinked via dots/Makefile (shared markdown skills)
+ "ai/.keep".text = "";
};
home.packages = with pkgs; [
.gitignore
@@ -29,3 +29,4 @@ hardware-configuration.nix
/tools/gcal-to-org/gcal-to-org
.playwright-mcp/
.claude/skills/
+node_modules/
globals.nix
@@ -45,6 +45,10 @@ _: {
id = "claude-sync"; # new consolidated folder
path = "/home/vincent/.local/share/claude-sync";
};
+ ai-sync = {
+ id = "ai-sync"; # unified AI agent storage (sessions, plans, learnings, research)
+ path = "/home/vincent/.local/share/ai-sync";
+ };
};
net = {
dns = {
@@ -186,6 +190,7 @@ _: {
screenshots = { };
wallpapers = { };
claude-sync = { };
+ ai-sync = { };
# TODO: implement paused or filter theses
# photos = {
# type = "receiveonly";
@@ -223,6 +228,7 @@ _: {
screenshots = { };
wallpapers = { };
claude-sync = { };
+ ai-sync = { };
# photos = {
# type = "receiveonly";
# paused = true; # TODO: implement this, start as paused