Updated extensions and global .gitignore
This commit is contained in:
parent
19f3c6a050
commit
411be9ab83
4 changed files with 243 additions and 0 deletions
4
.gitignore_global
Normal file
4
.gitignore_global
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
.env
|
||||||
|
PLAN.md
|
||||||
|
notes.md
|
||||||
|
.claude/settings.local.json
|
||||||
204
.pi/agent/extensions/search-history.ts
Normal file
204
.pi/agent/extensions/search-history.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
/**
|
||||||
|
* search-history extension
|
||||||
|
*
|
||||||
|
* Search through all of your chat histories and jump to a session.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* /search-history <query> Search all sessions across all projects
|
||||||
|
* /search-history --here <query> Search only sessions for the current cwd
|
||||||
|
* /search-history --re <regex> 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] <query>",
|
||||||
|
"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");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
24
Dockerfile
24
Dockerfile
|
|
@ -32,8 +32,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
zlib1g-dev \
|
zlib1g-dev \
|
||||||
libbz2-dev \
|
libbz2-dev \
|
||||||
|
liblzma-dev \
|
||||||
libreadline-dev \
|
libreadline-dev \
|
||||||
libsqlite3-dev \
|
libsqlite3-dev \
|
||||||
|
libncursesw5-dev \
|
||||||
|
xz-utils \
|
||||||
|
uuid-dev \
|
||||||
zsh \
|
zsh \
|
||||||
fd-find \
|
fd-find \
|
||||||
ripgrep \
|
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 && \
|
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
|
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
|
# Helm
|
||||||
RUN curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
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 && \
|
apt-get update && apt-get install -y acli && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
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
|
# Rust
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
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; \
|
mkdir -p /root/.aws && mv /tmp/build-context/aws-config /root/.aws/config; \
|
||||||
fi && rm -rf /tmp/build-context
|
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)
|
# Pi wrapper script (optional, only applied if bin/pi exists in build context)
|
||||||
COPY Dockerfile bin/p* /tmp/build-context/
|
COPY Dockerfile bin/p* /tmp/build-context/
|
||||||
RUN if [ -f /tmp/build-context/pi ]; then \
|
RUN if [ -f /tmp/build-context/pi ]; then \
|
||||||
|
|
|
||||||
|
|
@ -27,4 +27,15 @@ if [[ -d "$PI_SRC" ]]; then
|
||||||
done
|
done
|
||||||
fi
|
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 "$@"
|
exec "$@"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue