@8monkey/pi-context-history 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Infinite Monkey AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # pi-context-history
2
+
3
+ A [Pi](https://github.com/earendil-works/pi-coding-agent) extension that manages conversation context end-to-end. It trims old history, strips stale tool chatter from earlier turns, keeps a rolling session summary, and folds that summary back into the system prompt — so long-running conversations stay lean, focused, and continuous.
4
+
5
+ Each feature is independent and can be turned off individually, so you can run the whole pipeline or just the parts you want.
6
+
7
+ ## Features
8
+
9
+ | Feature | Hook | What it does |
10
+ |---|---|---|
11
+ | **Trim history** | `context` | Drops messages older than a configurable age from the context. |
12
+ | **Strip tool history** | `context` | Removes tool calls and results from prior turns, keeping the current turn's tool interactions intact. |
13
+ | **Generate summary** | `session_start` | Regenerates a stale rolling summary in the background when a session resumes; also available on demand via `/summarize-session`. |
14
+ | **Inject summary** | `before_agent_start` | Folds the rolling summary into the system prompt so the model continues with prior context. |
15
+
16
+ The summary features are a producer/consumer pair: **Generate summary** writes `~/.pi/agent/summary.md`, and **Inject summary** reads it.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pi install npm:@8monkey/pi-context-history
22
+ ```
23
+
24
+ ## How it works
25
+
26
+ - **Trim history.** On each `context` event the message list is filtered to those whose `timestamp` is within `PI_HISTORY_DAYS`. Messages exactly at the cutoff are kept.
27
+ - **Strip tool history.** Tool calls and results from earlier turns are removed; the current turn is left untouched. At the start of each agent loop the extension marks where the current turn begins (scanning back past the latest tool exchange) and holds that mark steady across the loop, so a running turn is never stripped mid-flight. Before the mark, tool results are dropped and assistant messages lose their tool-call blocks (if nothing else remains, the message goes too).
28
+ - **Generate summary.** When you resume a session whose summary has gone stale — older than `PI_SUMMARY_STALENESS_DAYS`, with a first message older than the window too — the extension rebuilds `~/.pi/agent/summary.md` by shelling out to `pi -p` (extensions, context files, and skills disabled). New and empty sessions are skipped. To override the built-in prompt, drop your own at `prompts/session-summary.md` (project `.pi/` wins over `~/.pi/`); it must contain the `{conversation_history}` placeholder.
29
+ - **Inject summary.** Before each agent run, when `~/.pi/agent/summary.md` exists, its contents are wrapped in a `<summary date="…">` block (the date is the file's modified time) followed by an `<additional_context>` note telling the model to maintain continuity and match the existing language and tone.
30
+
31
+ Zero runtime dependencies. Pi loads the TypeScript directly, so there's no build step. Runs under Node or Bun.
32
+
33
+ ## Command
34
+
35
+ | Command | Description |
36
+ |---|---|
37
+ | `/summarize-session` | Regenerate the current session's summary on demand, ignoring the staleness window. Writes `~/.pi/agent/summary.md` and reports success, failure, or an empty session. Available only when the **Generate summary** feature is enabled. |
38
+
39
+ ## Configuration
40
+
41
+ All configuration is via environment variables.
42
+
43
+ ### Feature toggles
44
+
45
+ Every feature is **on by default** and switched off by setting its flag to `false` or `0`:
46
+
47
+ | Variable | Default | Feature |
48
+ |---|---|---|
49
+ | `PI_TRIM_HISTORY` | on | Trim history |
50
+ | `PI_STRIP_TOOL_HISTORY` | on | Strip tool history |
51
+ | `PI_GENERATE_SUMMARY` | on | Generate summary |
52
+ | `PI_INJECT_SUMMARY` | on | Inject summary |
53
+
54
+ ### Settings
55
+
56
+ | Variable | Default | Description |
57
+ |---|---|---|
58
+ | `PI_HISTORY_DAYS` | `60` | Maximum age, in days, of messages kept in the context. |
59
+ | `PI_SUMMARY_STALENESS_DAYS` | `3` | How many days old a summary (and the session's first message) must be before automatic regeneration kicks in on session start. |
60
+
61
+ | File | Default | Description |
62
+ |---|---|---|
63
+ | `prompts/session-summary.md` | built-in default | Optional override for the summary prompt. Read from project `.pi/` first, then `~/.pi/`. Must contain the `{conversation_history}` placeholder. |
64
+
65
+ ## Development
66
+
67
+ ```bash
68
+ node --test # run tests
69
+ npm run typecheck
70
+ ```
71
+
72
+ ## License
73
+
74
+ MIT
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@8monkey/pi-context-history",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that manages conversation context: trims old history, strips stale tool calls, and keeps a rolling summary fed back into the system prompt. Each feature toggles independently.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/8monkey-ai/pi-context-history.git"
10
+ },
11
+ "homepage": "https://github.com/8monkey-ai/pi-context-history#readme",
12
+ "bugs": "https://github.com/8monkey-ai/pi-context-history/issues",
13
+ "files": [
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "pi": {
19
+ "extensions": [
20
+ "./src/index.ts"
21
+ ]
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "test": "node --test",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "peerDependencies": {
31
+ "@earendil-works/pi-coding-agent": "*"
32
+ },
33
+ "devDependencies": {
34
+ "@earendil-works/pi-coding-agent": "*",
35
+ "@types/node": "^22",
36
+ "typescript": "^5"
37
+ },
38
+ "keywords": [
39
+ "pi",
40
+ "pi-extension",
41
+ "context",
42
+ "history",
43
+ "summary",
44
+ "tool-calls"
45
+ ]
46
+ }
@@ -0,0 +1,4 @@
1
+ export function buildContextPrompt(basePrompt: string, summary: string | null, summaryDate: string) {
2
+ if (!summary) return basePrompt;
3
+ return `${basePrompt}\n<summary date="${summaryDate}">\n${summary}\n</summary>\n<additional_context>\nThe above is a summary of recent interactions with this contact. Use it to maintain continuity and provide contextual responses.\nContinue the conversation using the same language and tone and follow the language direction above.\n</additional_context>`;
4
+ }
package/src/config.ts ADDED
@@ -0,0 +1,8 @@
1
+ // On by default, so the bundle behaves like all four extensions installed; opt out per flag.
2
+ export function featureEnabled(flag: string) {
3
+ const value = process.env[flag];
4
+ return value !== "false" && value !== "0";
5
+ }
6
+
7
+ export const HISTORY_DAYS = Number(process.env["PI_HISTORY_DAYS"]) || 60;
8
+ export const SUMMARY_STALENESS_DAYS = Number(process.env["PI_SUMMARY_STALENESS_DAYS"]) || 3;
@@ -0,0 +1,4 @@
1
+ export const DEFAULT_SUMMARY_PROMPT = `You are summarizing a conversation so it can be continued later. Produce a concise, structured summary capturing the main topics, decisions, open questions, and any important context. Be factual and neutral.
2
+
3
+ Conversation:
4
+ {conversation_history}`;
package/src/filter.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function filterByAge<T extends { timestamp: number }>(messages: T[], now: number, maxAgeMs: number) {
2
+ return messages.filter((msg) => msg.timestamp >= now - maxAgeMs);
3
+ }
package/src/index.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
+ import { buildContextPrompt } from "./build-prompt.ts";
6
+ import { featureEnabled, HISTORY_DAYS, SUMMARY_STALENESS_DAYS } from "./config.ts";
7
+ import { filterByAge } from "./filter.ts";
8
+ import { piFileMtime, readPiFile } from "./pi-file.ts";
9
+ import { findBoundary, stripBeforeBoundary, type Message } from "./strip.ts";
10
+ import {
11
+ buildTranscript,
12
+ isStale,
13
+ resolvePromptTemplate,
14
+ runPiSummary,
15
+ type TranscriptEntry,
16
+ } from "./summary.ts";
17
+
18
+ const HISTORY_MS = HISTORY_DAYS * 86_400_000;
19
+ const SUMMARY_PATH = join(homedir(), ".pi", "agent", "summary.md");
20
+
21
+ function registerTrimHistory(pi: ExtensionAPI) {
22
+ pi.on("context", (event) => {
23
+ return { messages: filterByAge(event.messages, Date.now(), HISTORY_MS) };
24
+ });
25
+ }
26
+
27
+ function registerStripToolHistory(pi: ExtensionAPI) {
28
+ let boundaryIndex = 0;
29
+ let newLoopStarted = true;
30
+
31
+ pi.on("before_agent_start", () => {
32
+ newLoopStarted = true;
33
+ });
34
+
35
+ pi.on("context", (event) => {
36
+ const messages = event.messages as unknown as Message[];
37
+
38
+ // Pin the boundary for the whole loop; recomputing per context call would
39
+ // strip the running turn's own tool exchange mid-flight.
40
+ if (newLoopStarted) {
41
+ boundaryIndex = findBoundary(messages);
42
+ newLoopStarted = false;
43
+ }
44
+
45
+ const cleaned = stripBeforeBoundary(messages, boundaryIndex);
46
+ return { messages: cleaned as unknown as typeof event.messages };
47
+ });
48
+ }
49
+
50
+ function firstUserDate(entries: TranscriptEntry[]) {
51
+ const first = entries.find((e) => e.type === "message" && e.message?.role === "user");
52
+ return first ? new Date(first.timestamp) : null;
53
+ }
54
+
55
+ function generate(entries: TranscriptEntry[]): string | "empty" {
56
+ const history = buildTranscript(entries);
57
+ if (!history) return "empty";
58
+ const template = resolvePromptTemplate(readPiFile("prompts/session-summary.md"));
59
+ const summary = runPiSummary(template, history);
60
+ if (!summary) return "empty";
61
+ writeFileSync(SUMMARY_PATH, summary);
62
+ return summary;
63
+ }
64
+
65
+ function registerGenerateSummary(pi: ExtensionAPI) {
66
+ pi.on("session_start", (event, ctx) => {
67
+ if (event.reason === "new") return;
68
+ const entries = ctx.sessionManager.getEntries() as TranscriptEntry[];
69
+ if (!isStale(piFileMtime("agent/summary.md"), firstUserDate(entries), Date.now(), SUMMARY_STALENESS_DAYS)) {
70
+ return;
71
+ }
72
+ try {
73
+ generate(entries);
74
+ } catch {}
75
+ });
76
+
77
+ pi.registerCommand("summarize-session", {
78
+ description: "Regenerate the current session's summary now (ignores staleness)",
79
+ handler: async (_args, ctx: ExtensionCommandContext) => {
80
+ const entries = ctx.sessionManager.getEntries() as TranscriptEntry[];
81
+ let result: string | "empty";
82
+ try {
83
+ result = generate(entries);
84
+ } catch (err) {
85
+ if (ctx.hasUI) ctx.ui.notify(`Failed to generate summary: ${(err as Error).message}.`, "error");
86
+ return;
87
+ }
88
+ if (!ctx.hasUI) return;
89
+ if (result === "empty") {
90
+ ctx.ui.notify("Nothing to summarize in this session.", "info");
91
+ } else {
92
+ ctx.ui.notify("Session summary written to ~/.pi/agent/summary.md.", "info");
93
+ }
94
+ },
95
+ });
96
+ }
97
+
98
+ function registerInjectSummary(pi: ExtensionAPI) {
99
+ pi.on("before_agent_start", (event) => {
100
+ const summary = readPiFile("agent/summary.md");
101
+ const mtime = summary ? piFileMtime("agent/summary.md") : null;
102
+ const summaryDate = mtime ? (mtime.toISOString().split("T")[0] ?? "unknown") : "unknown";
103
+
104
+ const systemPrompt = buildContextPrompt(event.systemPrompt, summary, summaryDate);
105
+ return { systemPrompt };
106
+ });
107
+ }
108
+
109
+ export default function (pi: ExtensionAPI) {
110
+ if (featureEnabled("PI_TRIM_HISTORY")) registerTrimHistory(pi);
111
+ if (featureEnabled("PI_STRIP_TOOL_HISTORY")) registerStripToolHistory(pi);
112
+ if (featureEnabled("PI_GENERATE_SUMMARY")) registerGenerateSummary(pi);
113
+ if (featureEnabled("PI_INJECT_SUMMARY")) registerInjectSummary(pi);
114
+ }
package/src/pi-file.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const PI_DIR = join(homedir(), ".pi");
6
+ const PROJECT_PI_DIR = join(process.cwd(), ".pi");
7
+
8
+ // Project `.pi/` wins over `~/.pi/` when both define the same file.
9
+ export function readPiFile(relative: string): string | null {
10
+ let content: string | null = null;
11
+ try {
12
+ content = readFileSync(join(PI_DIR, relative), "utf-8").trim() || null;
13
+ } catch {}
14
+ try {
15
+ content = readFileSync(join(PROJECT_PI_DIR, relative), "utf-8").trim() || null;
16
+ } catch {}
17
+ return content;
18
+ }
19
+
20
+ export function piFileMtime(relative: string): Date | null {
21
+ try {
22
+ return statSync(join(PI_DIR, relative)).mtime;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
package/src/strip.ts ADDED
@@ -0,0 +1,44 @@
1
+ // Minimal message shape, to avoid a runtime dependency on the pi package.
2
+ export type Message = {
3
+ role: string;
4
+ content: { type: string }[];
5
+ };
6
+
7
+ // Index of the first message in the current turn: scan from the end, skipping the
8
+ // trailing tool exchange (tool results + the assistant message that called them),
9
+ // so the current turn's tools survive while earlier ones can be stripped.
10
+ export function findBoundary(messages: Message[]) {
11
+ let boundary = messages.length;
12
+ while (boundary > 0 && messages[boundary - 1]?.role === "toolResult") {
13
+ boundary--;
14
+ }
15
+ if (boundary > 0) {
16
+ const prev = messages[boundary - 1];
17
+ if (prev?.role === "assistant" && prev.content.some((b) => b.type === "toolCall")) {
18
+ boundary--;
19
+ }
20
+ }
21
+ return boundary;
22
+ }
23
+
24
+ export function stripBeforeBoundary(messages: Message[], boundaryIndex: number) {
25
+ const cleaned: Message[] = [];
26
+ for (let i = 0; i < messages.length; i++) {
27
+ const msg = messages[i];
28
+ if (!msg) continue;
29
+ if (i >= boundaryIndex) {
30
+ cleaned.push(msg);
31
+ continue;
32
+ }
33
+ if (msg.role === "toolResult") continue;
34
+ if (msg.role === "assistant") {
35
+ const hasNonToolContent = msg.content.some((block) => block.type !== "toolCall");
36
+ if (!hasNonToolContent) continue;
37
+ const strippedContent = msg.content.filter((block) => block.type !== "toolCall");
38
+ cleaned.push({ ...msg, content: strippedContent });
39
+ continue;
40
+ }
41
+ cleaned.push(msg);
42
+ }
43
+ return cleaned;
44
+ }
package/src/summary.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { DEFAULT_SUMMARY_PROMPT } from "./default-prompt.ts";
3
+
4
+ const DAY_MS = 86_400_000;
5
+
6
+ export type TranscriptEntry = {
7
+ type: string;
8
+ timestamp: string;
9
+ message?: {
10
+ role?: string;
11
+ content?: string | { type: string; text?: string }[];
12
+ };
13
+ };
14
+
15
+ export function buildTranscript(entries: TranscriptEntry[]) {
16
+ return entries
17
+ .filter((e) => e.type === "message")
18
+ .map((e) => {
19
+ const msg = e.message;
20
+ if (msg?.role !== "user" && msg?.role !== "assistant") return null;
21
+ const text = Array.isArray(msg.content)
22
+ ? msg.content
23
+ .filter((b) => b.type === "text")
24
+ .map((b) => b.text)
25
+ .join("\n")
26
+ : String(msg.content || "");
27
+ if (!text.trim()) return null;
28
+ const ts = new Date(e.timestamp).toISOString().slice(0, 16).replace("T", " ");
29
+ return `[${ts}] ${msg.role}: ${text.trim()}`;
30
+ })
31
+ .filter(Boolean)
32
+ .join("\n\n");
33
+ }
34
+
35
+ function daysSince(date: Date, now: number) {
36
+ return (now - date.getTime()) / DAY_MS;
37
+ }
38
+
39
+ export function isStale(summaryMtime: Date | null, firstUserDate: Date | null, now: number, stalenessDays: number) {
40
+ if (summaryMtime && daysSince(summaryMtime, now) < stalenessDays) return false;
41
+ if (!firstUserDate || daysSince(firstUserDate, now) < stalenessDays) return false;
42
+ return true;
43
+ }
44
+
45
+ export function resolvePromptTemplate(fileContents: string | null) {
46
+ return fileContents ?? DEFAULT_SUMMARY_PROMPT;
47
+ }
48
+
49
+ export function runPiSummary(template: string, history: string) {
50
+ return execFileSync(
51
+ "pi",
52
+ [
53
+ "-p",
54
+ "--no-session",
55
+ "--no-extensions",
56
+ "--no-context-files",
57
+ "--no-skills",
58
+ "--system-prompt",
59
+ template.replace("{conversation_history}", history),
60
+ "Generate a detailed summary of the conversation. Respond with the summary only. No comments or other text.",
61
+ ],
62
+ { encoding: "utf-8", timeout: 60_000 },
63
+ ).trim();
64
+ }