From 411be9ab83f420e52a5646b2e9eccb47e852dbdd Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Tue, 26 May 2026 10:30:21 -0400 Subject: [PATCH] Updated extensions and global .gitignore --- .gitignore_global | 4 + .pi/agent/extensions/search-history.ts | 204 +++++++++++++++++++++++++ Dockerfile | 24 +++ entrypoint.sh | 11 ++ 4 files changed, 243 insertions(+) create mode 100644 .gitignore_global create mode 100644 .pi/agent/extensions/search-history.ts diff --git a/.gitignore_global b/.gitignore_global new file mode 100644 index 0000000..385e1d6 --- /dev/null +++ b/.gitignore_global @@ -0,0 +1,4 @@ +.env +PLAN.md +notes.md +.claude/settings.local.json diff --git a/.pi/agent/extensions/search-history.ts b/.pi/agent/extensions/search-history.ts new file mode 100644 index 0000000..b2894fa --- /dev/null +++ b/.pi/agent/extensions/search-history.ts @@ -0,0 +1,204 @@ +/** + * search-history extension + * + * Search through all of your chat histories and jump to a session. + * + * Usage: + * /search-history Search all sessions across all projects + * /search-history --here Search only sessions for the current cwd + * /search-history --re Treat query as a regex (case-insensitive) + * + * Behavior: + * - Lists every session whose conversation text matches the query + * - Shows the matching line as the selection label so you can disambiguate + * - Selecting a session switches to it at its existing leaf (latest message) + */ + +import type { ExtensionAPI, SessionInfo } from "@earendil-works/pi-coding-agent"; +import { SessionManager } from "@earendil-works/pi-coding-agent"; + +interface Match { + info: SessionInfo; + line: string; // matched line (or windowed snippet) + lineNumber: number; +} + +const MAX_RESULTS = 200; +const SNIPPET_RADIUS = 60; // chars around match if line is too long +const MAX_LINE_DISPLAY = 160; + +function compactWhitespace(s: string): string { + return s.replace(/\s+/g, " ").trim(); +} + +function snippetAround(line: string, idx: number, qlen: number): string { + if (line.length <= MAX_LINE_DISPLAY) return line; + const start = Math.max(0, idx - SNIPPET_RADIUS); + const end = Math.min(line.length, idx + qlen + SNIPPET_RADIUS); + const prefix = start > 0 ? "…" : ""; + const suffix = end < line.length ? "…" : ""; + return prefix + line.slice(start, end) + suffix; +} + +function findMatch( + haystack: string, + pattern: RegExp, +): { line: string; lineNumber: number } | undefined { + if (!haystack) return undefined; + const lines = haystack.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + pattern.lastIndex = 0; + const m = pattern.exec(line); + if (m) { + const cleaned = compactWhitespace(line); + // Re-locate the match in the cleaned string for snippet windowing + pattern.lastIndex = 0; + const m2 = pattern.exec(cleaned); + const idx = m2 ? m2.index : 0; + const qlen = m2 ? m2[0].length : m[0].length; + return { line: snippetAround(cleaned, idx, qlen), lineNumber: i + 1 }; + } + } + return undefined; +} + +function buildPattern(query: string, isRegex: boolean): RegExp | undefined { + try { + if (isRegex) return new RegExp(query, "i"); + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(escaped, "i"); + } catch { + return undefined; + } +} + +function relTime(date: Date): string { + const diffMs = Date.now() - date.getTime(); + const sec = Math.floor(diffMs / 1000); + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ago`; + const d = Math.floor(hr / 24); + if (d < 30) return `${d}d ago`; + const mo = Math.floor(d / 30); + if (mo < 12) return `${mo}mo ago`; + return `${Math.floor(mo / 12)}y ago`; +} + +function projectLabel(cwd: string): string { + if (!cwd) return "(unknown)"; + const parts = cwd.split("/").filter(Boolean); + return parts[parts.length - 1] ?? cwd; +} + +function formatItem(m: Match): string { + const when = relTime(m.info.modified); + const proj = projectLabel(m.info.cwd); + const title = m.info.name?.trim() || compactWhitespace(m.info.firstMessage) || "(empty)"; + const titleTrim = title.length > 60 ? title.slice(0, 59) + "…" : title; + const lineTrim = m.line.length > MAX_LINE_DISPLAY ? m.line.slice(0, MAX_LINE_DISPLAY - 1) + "…" : m.line; + return `[${when}] ${proj} — ${titleTrim}\n ↳ ${lineTrim}`; +} + +function parseArgs(raw: string): { query: string; here: boolean; regex: boolean } { + const tokens = raw.trim().split(/\s+/).filter(Boolean); + let here = false; + let regex = false; + const rest: string[] = []; + for (const t of tokens) { + if (t === "--here" || t === "-h") here = true; + else if (t === "--re" || t === "--regex" || t === "-r") regex = true; + else rest.push(t); + } + return { query: rest.join(" "), here, regex }; +} + +export default function (pi: ExtensionAPI) { + pi.registerCommand("search-history", { + description: "Search all chat histories and jump to a session", + handler: async (rawArgs, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("search-history requires interactive mode", "error"); + return; + } + + const { query, here, regex } = parseArgs(rawArgs); + if (!query) { + ctx.ui.notify( + "Usage: /search-history [--here] [--re] ", + "error", + ); + return; + } + + const pattern = buildPattern(query, regex); + if (!pattern) { + ctx.ui.notify(`Invalid regex: ${query}`, "error"); + return; + } + + ctx.ui.setStatus("search-history", `searching: ${query}`); + let sessions: SessionInfo[]; + try { + sessions = here + ? await SessionManager.list(ctx.cwd) + : await SessionManager.listAll(); + } catch (err) { + ctx.ui.setStatus("search-history", undefined); + ctx.ui.notify(`Failed to list sessions: ${(err as Error).message}`, "error"); + return; + } + + const currentFile = ctx.sessionManager.getSessionFile(); + const matches: Match[] = []; + for (const info of sessions) { + if (info.path === currentFile) continue; // skip self + const hit = findMatch(info.allMessagesText ?? "", pattern); + if (hit) { + matches.push({ info, line: hit.line, lineNumber: hit.lineNumber }); + if (matches.length >= MAX_RESULTS) break; + } + } + + ctx.ui.setStatus("search-history", undefined); + + if (matches.length === 0) { + ctx.ui.notify(`No sessions match "${query}"`, "info"); + return; + } + + // Most recent first + matches.sort((a, b) => b.info.modified.getTime() - a.info.modified.getTime()); + + const items = matches.map(formatItem); + const selected = await ctx.ui.select( + `History matches for "${query}" (${matches.length})`, + items, + ); + + if (!selected) return; + const chosen = matches[items.indexOf(selected)]; + if (!chosen) return; + + const result = await ctx.switchSession(chosen.info.path, { + withSession: async (newCtx) => { + newCtx.ui.notify( + `Resumed: ${projectLabel(chosen.info.cwd)} — ${ + chosen.info.name?.trim() || + compactWhitespace(chosen.info.firstMessage) || + "session" + }`, + "info", + ); + }, + }); + + if (result.cancelled) { + ctx.ui.notify("Switch cancelled", "info"); + } + }, + }); +} diff --git a/Dockerfile b/Dockerfile index 432bfed..e8b3481 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,8 +32,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libssl-dev \ zlib1g-dev \ libbz2-dev \ + liblzma-dev \ libreadline-dev \ libsqlite3-dev \ + libncursesw5-dev \ + xz-utils \ + uuid-dev \ zsh \ fd-find \ ripgrep \ @@ -103,6 +107,13 @@ RUN ARCH=$(dpkg --print-architecture) && \ curl -fsSL https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/${ARCH}/kubectl -o /usr/local/bin/kubectl && \ chmod +x /usr/local/bin/kubectl + +# yq (mikefarah) +ARG YQ_VERSION=v4.44.3 +RUN ARCH=$(dpkg --print-architecture) && \ + curl -fsSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_${ARCH}" -o /usr/local/bin/yq && \ + chmod +x /usr/local/bin/yq + # Helm RUN curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash @@ -140,6 +151,14 @@ RUN mkdir -p -m 755 /etc/apt/keyrings && \ apt-get update && apt-get install -y acli && \ rm -rf /var/lib/apt/lists/* +# GitHub CLI (gh) +RUN mkdir -p -m 755 /etc/apt/keyrings && \ + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | gpg --dearmor -o /etc/apt/keyrings/githubcli-archive-keyring.gpg && \ + chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y gh && \ + rm -rf /var/lib/apt/lists/* + # Rust RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y ENV PATH="/root/.cargo/bin:${PATH}" @@ -166,6 +185,11 @@ RUN if [ -f /tmp/build-context/aws-config ]; then \ mkdir -p /root/.aws && mv /tmp/build-context/aws-config /root/.aws/config; \ fi && rm -rf /tmp/build-context +# Global gitignore: lives on the host at ~/repos/dev_sandbox/.gitignore_global +# (bind-mounted to /workspace/dev_sandbox/.gitignore_global). Symlinked into +# place by entrypoint.sh so host edits take effect without a rebuild. +RUN git config --system core.excludesfile /root/.gitignore_global + # Pi wrapper script (optional, only applied if bin/pi exists in build context) COPY Dockerfile bin/p* /tmp/build-context/ RUN if [ -f /tmp/build-context/pi ]; then \ diff --git a/entrypoint.sh b/entrypoint.sh index cf23d09..099536a 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -27,4 +27,15 @@ if [[ -d "$PI_SRC" ]]; then done fi +# Global gitignore lives on the host at ~/.gitignore_global, symlinked from +# the host into the workspace at /workspace/dev_sandbox/.gitignore_global. +# Mirror it to /root/.gitignore_global so git's --system core.excludesfile +# (set in the Dockerfile) resolves. Symlink so host edits apply immediately. +GITIGNORE_SRC="/workspace/dev_sandbox/.gitignore_global" +GITIGNORE_DST="/root/.gitignore_global" +if [[ -e "$GITIGNORE_SRC" ]]; then + rm -f "$GITIGNORE_DST" + ln -sf "$GITIGNORE_SRC" "$GITIGNORE_DST" +fi + exec "$@"