@pi-unipi/compactor 0.1.1
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/README.md +86 -0
- package/package.json +54 -0
- package/skills/compactor/SKILL.md +74 -0
- package/skills/compactor-doctor/SKILL.md +74 -0
- package/skills/compactor-ops/SKILL.md +65 -0
- package/skills/compactor-stats/SKILL.md +49 -0
- package/skills/compactor-tools/SKILL.md +120 -0
- package/src/commands/index.ts +248 -0
- package/src/compaction/brief.ts +334 -0
- package/src/compaction/build-sections.ts +77 -0
- package/src/compaction/content.ts +47 -0
- package/src/compaction/cut.ts +80 -0
- package/src/compaction/extract/commits.ts +52 -0
- package/src/compaction/extract/files.ts +58 -0
- package/src/compaction/extract/goals.ts +36 -0
- package/src/compaction/extract/preferences.ts +40 -0
- package/src/compaction/filter-noise.ts +46 -0
- package/src/compaction/format.ts +48 -0
- package/src/compaction/hooks.ts +145 -0
- package/src/compaction/merge.ts +113 -0
- package/src/compaction/normalize.ts +68 -0
- package/src/compaction/recall-scope.ts +32 -0
- package/src/compaction/sanitize.ts +12 -0
- package/src/compaction/search-entries.ts +101 -0
- package/src/compaction/sections.ts +15 -0
- package/src/compaction/summarize.ts +29 -0
- package/src/config/manager.ts +89 -0
- package/src/config/presets.ts +83 -0
- package/src/config/schema.ts +55 -0
- package/src/display/bash-display.ts +28 -0
- package/src/display/diff-presentation.ts +20 -0
- package/src/display/diff-renderer.ts +255 -0
- package/src/display/line-width-safety.ts +16 -0
- package/src/display/pending-diff-preview.ts +51 -0
- package/src/display/render-utils.ts +52 -0
- package/src/display/thinking-label.ts +18 -0
- package/src/display/tool-overrides.ts +136 -0
- package/src/display/user-message-box.ts +16 -0
- package/src/executor/executor.ts +242 -0
- package/src/executor/runtime.ts +125 -0
- package/src/index.ts +211 -0
- package/src/info-screen.ts +60 -0
- package/src/security/evaluator.ts +142 -0
- package/src/security/policy.ts +74 -0
- package/src/security/scanner.ts +65 -0
- package/src/session/db.ts +237 -0
- package/src/session/extract.ts +107 -0
- package/src/session/resume-inject.ts +25 -0
- package/src/session/snapshot.ts +326 -0
- package/src/store/chunking.ts +126 -0
- package/src/store/db-base.ts +79 -0
- package/src/store/index.ts +364 -0
- package/src/tools/compact.ts +20 -0
- package/src/tools/ctx-batch-execute.ts +53 -0
- package/src/tools/ctx-doctor.ts +78 -0
- package/src/tools/ctx-execute-file.ts +26 -0
- package/src/tools/ctx-execute.ts +21 -0
- package/src/tools/ctx-fetch-and-index.ts +37 -0
- package/src/tools/ctx-index.ts +42 -0
- package/src/tools/ctx-search.ts +23 -0
- package/src/tools/ctx-stats.ts +37 -0
- package/src/tools/register.ts +360 -0
- package/src/tools/vcc-recall.ts +64 -0
- package/src/tui/settings-overlay.ts +290 -0
- package/src/types.ts +269 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content helpers for compaction pipeline
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Extract plain text from message content */
|
|
6
|
+
export function textOf(content: unknown): string {
|
|
7
|
+
if (typeof content === "string") return content;
|
|
8
|
+
if (Array.isArray(content)) {
|
|
9
|
+
return content
|
|
10
|
+
.map((c: any) => {
|
|
11
|
+
if (c?.type === "text") return c.text ?? "";
|
|
12
|
+
if (c?.type === "toolCall") return `[toolCall:${c.name}]`;
|
|
13
|
+
if (c?.type === "thinking") return "[thinking]";
|
|
14
|
+
if (c?.type === "image") return `[image:${c.mimeType}]`;
|
|
15
|
+
return `[${c?.type ?? "unknown"}]`;
|
|
16
|
+
})
|
|
17
|
+
.join("\n");
|
|
18
|
+
}
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Clip text to max length, adding ellipsis */
|
|
23
|
+
export function clip(text: string, max: number): string {
|
|
24
|
+
if (text.length <= max) return text;
|
|
25
|
+
return text.slice(0, max - 3) + "...";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Clip to first sentence within max length */
|
|
29
|
+
export function clipSentence(text: string, max: number): string {
|
|
30
|
+
const trimmed = text.trim();
|
|
31
|
+
const sentenceEnd = trimmed.search(/[.!?](?:\s|$)/);
|
|
32
|
+
if (sentenceEnd > 0 && sentenceEnd < max) {
|
|
33
|
+
return trimmed.slice(0, sentenceEnd + 1);
|
|
34
|
+
}
|
|
35
|
+
return clip(trimmed, max);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** First non-empty line, clipped */
|
|
39
|
+
export function firstLine(text: string, max: number): string {
|
|
40
|
+
const line = text.split(/\r?\n/).find((l) => l.trim()) ?? "";
|
|
41
|
+
return clip(line.trim(), max);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Non-empty lines from text */
|
|
45
|
+
export function nonEmptyLines(text: string): string[] {
|
|
46
|
+
return text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
47
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cut logic — buildOwnCut for determining compaction boundaries
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type OwnCutCancelReason =
|
|
6
|
+
| "no_live_messages"
|
|
7
|
+
| "too_few_live_messages"
|
|
8
|
+
| "no_user_message";
|
|
9
|
+
|
|
10
|
+
export type OwnCutResult =
|
|
11
|
+
| { ok: true; messages: any[]; firstKeptEntryId: string; compactAll: boolean }
|
|
12
|
+
| { ok: false; reason: OwnCutCancelReason };
|
|
13
|
+
|
|
14
|
+
interface EntryWithMessage {
|
|
15
|
+
entry: { id: string; type: string };
|
|
16
|
+
message: { role: string; content: unknown };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildOwnCut(branchEntries: any[]): OwnCutResult {
|
|
20
|
+
let lastCompactionIdx = -1;
|
|
21
|
+
let lastKeptId: string | undefined;
|
|
22
|
+
for (let i = branchEntries.length - 1; i >= 0; i--) {
|
|
23
|
+
if (branchEntries[i].type === "compaction") {
|
|
24
|
+
lastCompactionIdx = i;
|
|
25
|
+
lastKeptId = branchEntries[i].firstKeptEntryId;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const hasPriorCompaction = lastCompactionIdx >= 0;
|
|
31
|
+
const hasValidKeptId = !!lastKeptId && branchEntries.some((e: any) => e.id === lastKeptId);
|
|
32
|
+
const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
|
|
33
|
+
|
|
34
|
+
const liveMessages: EntryWithMessage[] = [];
|
|
35
|
+
if (orphanRecovery) {
|
|
36
|
+
for (let i = lastCompactionIdx + 1; i < branchEntries.length; i++) {
|
|
37
|
+
const e = branchEntries[i];
|
|
38
|
+
if (e.type === "compaction") continue;
|
|
39
|
+
if (e.type === "message" && e.message) {
|
|
40
|
+
liveMessages.push({ entry: e, message: e.message });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
let foundKept = !lastKeptId;
|
|
45
|
+
for (const e of branchEntries) {
|
|
46
|
+
if (!foundKept && e.id === lastKeptId) foundKept = true;
|
|
47
|
+
if (!foundKept) continue;
|
|
48
|
+
if (e.type === "compaction") continue;
|
|
49
|
+
if (e.type === "message" && e.message) {
|
|
50
|
+
liveMessages.push({ entry: e, message: e.message });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (liveMessages.length === 0) return { ok: false, reason: "no_live_messages" };
|
|
56
|
+
if (liveMessages.length < 2) return { ok: false, reason: "too_few_live_messages" };
|
|
57
|
+
|
|
58
|
+
let cutIdx = liveMessages.length - 1;
|
|
59
|
+
while (cutIdx > 0 && liveMessages[cutIdx].message.role !== "user") {
|
|
60
|
+
cutIdx--;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (cutIdx <= 0) {
|
|
64
|
+
const hasUser = liveMessages.some((m) => m.message.role === "user");
|
|
65
|
+
if (!hasUser) return { ok: false, reason: "no_user_message" };
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
messages: liveMessages.map((e) => e.message),
|
|
69
|
+
firstKeptEntryId: "",
|
|
70
|
+
compactAll: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
messages: liveMessages.slice(0, cutIdx).map((e) => e.message),
|
|
77
|
+
firstKeptEntryId: liveMessages[cutIdx].entry.id,
|
|
78
|
+
compactAll: false,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit extraction — git commit parsing, hash pairing, last 8
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { NormalizedBlock } from "../../types.js";
|
|
6
|
+
|
|
7
|
+
const COMMIT_HASH_RE = /\b[0-9a-f]{7,40}\b/gi;
|
|
8
|
+
const COMMIT_MSG_RE = /(?:commit|committed|git\s+(?:commit|push|merge|rebase))[^.]*?["']([^"']+)["']/i;
|
|
9
|
+
|
|
10
|
+
export interface CommitInfo {
|
|
11
|
+
hash?: string;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function extractCommits(blocks: NormalizedBlock[]): CommitInfo[] {
|
|
16
|
+
const commits: CommitInfo[] = [];
|
|
17
|
+
const seen = new Set<string>();
|
|
18
|
+
|
|
19
|
+
for (const b of blocks) {
|
|
20
|
+
if (b.kind !== "user" && b.kind !== "assistant" && b.kind !== "tool_result") continue;
|
|
21
|
+
const text = b.text;
|
|
22
|
+
const hashes = text.match(COMMIT_HASH_RE) ?? [];
|
|
23
|
+
const msgMatch = text.match(COMMIT_MSG_RE);
|
|
24
|
+
const message = msgMatch?.[1]?.trim();
|
|
25
|
+
|
|
26
|
+
for (const hash of hashes) {
|
|
27
|
+
const key = hash.toLowerCase();
|
|
28
|
+
if (seen.has(key)) continue;
|
|
29
|
+
seen.add(key);
|
|
30
|
+
commits.push({ hash: hash.slice(0, 8), message: message || "" });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Also catch "committed: message" without hash
|
|
34
|
+
if (!hashes.length && message && message.length > 5) {
|
|
35
|
+
const key = message.toLowerCase().slice(0, 60);
|
|
36
|
+
if (!seen.has(key)) {
|
|
37
|
+
seen.add(key);
|
|
38
|
+
commits.push({ message });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return commits.slice(-8);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatCommits(commits: CommitInfo[]): string[] {
|
|
47
|
+
return commits.map((c) => {
|
|
48
|
+
if (c.hash && c.message) return `${c.hash} — ${c.message}`;
|
|
49
|
+
if (c.hash) return c.hash;
|
|
50
|
+
return c.message;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File tracking — Modified/Created/Read, dedup, path trimming
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { NormalizedBlock } from "../../types.js";
|
|
6
|
+
|
|
7
|
+
const PATH_RE = /(?:[\w\-]+\/)+[\w\-]+(?:\.[\w\-]+)?/g;
|
|
8
|
+
const EDIT_RE = /\b(edit|modify|update|change|patch|rewrite|refactor)\b/i;
|
|
9
|
+
const CREATE_RE = /\b(create|write|add|new|generate|make)\b/i;
|
|
10
|
+
const READ_RE = /\b(read|view|show|inspect|check|look|open|cat|head|tail|grep|find|ls)\b/i;
|
|
11
|
+
|
|
12
|
+
export interface FileActivity {
|
|
13
|
+
modified: Set<string>;
|
|
14
|
+
created: Set<string>;
|
|
15
|
+
read: Set<string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function extractFiles(blocks: NormalizedBlock[]): FileActivity {
|
|
19
|
+
const activity: FileActivity = {
|
|
20
|
+
modified: new Set(),
|
|
21
|
+
created: new Set(),
|
|
22
|
+
read: new Set(),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
for (const b of blocks) {
|
|
26
|
+
if (b.kind === "tool_call") {
|
|
27
|
+
const path = extractPath(b.args);
|
|
28
|
+
if (!path) continue;
|
|
29
|
+
if (b.name === "edit" || b.name === "Edit") activity.modified.add(path);
|
|
30
|
+
else if (b.name === "write" || b.name === "Write") activity.created.add(path);
|
|
31
|
+
else if (b.name === "read" || b.name === "Read") activity.read.add(path);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (b.kind !== "user" && b.kind !== "assistant") continue;
|
|
36
|
+
const text = b.text;
|
|
37
|
+
const paths = text.match(PATH_RE) ?? [];
|
|
38
|
+
for (const p of paths) {
|
|
39
|
+
if (p.split("/").length < 2) continue; // require at least one directory
|
|
40
|
+
const context = text.slice(Math.max(0, text.indexOf(p) - 40), text.indexOf(p) + p.length + 40);
|
|
41
|
+
if (EDIT_RE.test(context)) activity.modified.add(p);
|
|
42
|
+
else if (CREATE_RE.test(context)) activity.created.add(p);
|
|
43
|
+
else if (READ_RE.test(context)) activity.read.add(p);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return activity;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Extract a file path from tool args */
|
|
51
|
+
export function extractPath(args: Record<string, unknown>): string | undefined {
|
|
52
|
+
const candidates = ["file_path", "path", "filePath", "filepath", "file"];
|
|
53
|
+
for (const key of candidates) {
|
|
54
|
+
const val = args[key];
|
|
55
|
+
if (typeof val === "string" && val.length > 0) return val;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal extraction — regex-based, scope changes, task verbs
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { NormalizedBlock } from "../../types.js";
|
|
6
|
+
import { nonEmptyLines } from "../content.js";
|
|
7
|
+
|
|
8
|
+
const SCOPE_CHANGE_RE = /\b(scope|focus|switch|move|now|instead|rather than|change|shift|pivot|let's|let us)\b/i;
|
|
9
|
+
const TASK_VERB_RE = /\b(add|create|build|implement|fix|refactor|update|remove|delete|rename|extract|merge|split|convert|migrate|optimize|test|write|generate|setup|configure|install|deploy|release|publish|document|review|audit|analyze|debug|resolve|handle|support|enable|disable|integrate|connect|sync|import|export|validate|verify|check|ensure|make|set|get|put|post|patch|delete)\b/i;
|
|
10
|
+
const GOAL_PREFIX_RE = /^(?:Goal|Objective|Task|Plan|Target|Aim|Intent|Purpose|Mission|Action item|To-do|TODO|FIXME|NOTE|IDEA|HACK|BUG|FEATURE|STORY|EPIC):?\s*/i;
|
|
11
|
+
|
|
12
|
+
export function extractGoals(blocks: NormalizedBlock[]): string[] {
|
|
13
|
+
const goals: string[] = [];
|
|
14
|
+
const seen = new Set<string>();
|
|
15
|
+
|
|
16
|
+
for (const b of blocks) {
|
|
17
|
+
if (b.kind !== "user" && b.kind !== "assistant") continue;
|
|
18
|
+
for (const line of nonEmptyLines(b.text)) {
|
|
19
|
+
let candidate = line;
|
|
20
|
+
// Strip goal prefix
|
|
21
|
+
candidate = candidate.replace(GOAL_PREFIX_RE, "").trim();
|
|
22
|
+
if (candidate.length < 10) continue;
|
|
23
|
+
// Must contain a task verb or scope change
|
|
24
|
+
if (!TASK_VERB_RE.test(candidate) && !SCOPE_CHANGE_RE.test(candidate)) continue;
|
|
25
|
+
// Deduplicate
|
|
26
|
+
const key = candidate.toLowerCase().slice(0, 80);
|
|
27
|
+
if (seen.has(key)) continue;
|
|
28
|
+
seen.add(key);
|
|
29
|
+
goals.push(candidate);
|
|
30
|
+
if (goals.length >= 6) break;
|
|
31
|
+
}
|
|
32
|
+
if (goals.length >= 6) break;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return goals;
|
|
36
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User preference extraction — track explicit preferences and constraints
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { NormalizedBlock } from "../../types.js";
|
|
6
|
+
import { nonEmptyLines } from "../content.js";
|
|
7
|
+
|
|
8
|
+
const PREF_RE = /\b(prefer|preference|want|would like|should|must|need to|important|critical|avoid|don't|do not|never|always|only|make sure|ensure|remember|keep in mind|note that)\b/i;
|
|
9
|
+
const PREF_PREFIX_RE = /^(?:Preference|Note|Remember|Keep in mind|Important|Critical|Constraint|Rule|Guideline|Style|Format):?\s*/i;
|
|
10
|
+
|
|
11
|
+
export function extractPreferences(blocks: NormalizedBlock[]): string[] {
|
|
12
|
+
const prefs: string[] = [];
|
|
13
|
+
const seen = new Set<string>();
|
|
14
|
+
|
|
15
|
+
for (const b of blocks) {
|
|
16
|
+
if (b.kind !== "user") continue;
|
|
17
|
+
for (const line of nonEmptyLines(b.text)) {
|
|
18
|
+
let candidate = line.replace(PREF_PREFIX_RE, "").trim();
|
|
19
|
+
if (candidate.length < 10) continue;
|
|
20
|
+
if (!PREF_RE.test(candidate)) continue;
|
|
21
|
+
const key = candidate.toLowerCase().slice(0, 80);
|
|
22
|
+
if (seen.has(key)) continue;
|
|
23
|
+
seen.add(key);
|
|
24
|
+
prefs.push(candidate);
|
|
25
|
+
if (prefs.length >= 10) break;
|
|
26
|
+
}
|
|
27
|
+
if (prefs.length >= 10) break;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return prefs;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Remove preferences that overlap with goals */
|
|
34
|
+
export function dedupPreferencesAgainstGoals(prefs: string[], goals: string[]): string[] {
|
|
35
|
+
const goalSet = new Set(goals.map((g) => g.toLowerCase().slice(0, 60)));
|
|
36
|
+
return prefs.filter((p) => {
|
|
37
|
+
const key = p.toLowerCase().slice(0, 60);
|
|
38
|
+
return !goalSet.has(key);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 2: Filter Noise — Remove thinking blocks, noise tools, XML wrappers, stop words
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { NormalizedBlock } from "../types.js";
|
|
6
|
+
|
|
7
|
+
const NOISE_TOOLS = new Set([
|
|
8
|
+
"TodoWrite", "TodoRead", "ToolSearch", "WebSearch",
|
|
9
|
+
"AskUser", "ExitSpecMode", "GenerateDroid",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const NOISE_STRINGS = [
|
|
13
|
+
"Continue from where you left off.",
|
|
14
|
+
"No response requested.",
|
|
15
|
+
"IMPORTANT: TodoWrite was not called yet.",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const XML_WRAPPER_RE = /<(system-reminder|ide_opened_file|command-message|context-window-usage)[^>]*>[\s\S]*?<\/\1>/g;
|
|
19
|
+
|
|
20
|
+
const isNoiseUserBlock = (text: string): boolean => {
|
|
21
|
+
const trimmed = text.trim();
|
|
22
|
+
if (NOISE_STRINGS.some((s) => trimmed.includes(s))) return true;
|
|
23
|
+
const stripped = trimmed.replace(XML_WRAPPER_RE, "").trim();
|
|
24
|
+
return stripped.length === 0;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const cleanUserText = (text: string): string =>
|
|
28
|
+
text.replace(XML_WRAPPER_RE, "").trim();
|
|
29
|
+
|
|
30
|
+
export const filterNoise = (blocks: NormalizedBlock[]): NormalizedBlock[] => {
|
|
31
|
+
const out: NormalizedBlock[] = [];
|
|
32
|
+
for (const b of blocks) {
|
|
33
|
+
if (b.kind === "thinking") continue;
|
|
34
|
+
if (b.kind === "tool_call" && NOISE_TOOLS.has(b.name)) continue;
|
|
35
|
+
if (b.kind === "tool_result" && NOISE_TOOLS.has(b.name)) continue;
|
|
36
|
+
if (b.kind === "user") {
|
|
37
|
+
if (isNoiseUserBlock(b.text)) continue;
|
|
38
|
+
const cleaned = cleanUserText(b.text);
|
|
39
|
+
if (!cleaned) continue;
|
|
40
|
+
out.push({ kind: "user", text: cleaned });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
out.push(b);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 5: Format — Render sections with separators
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SectionData } from "./sections.js";
|
|
6
|
+
|
|
7
|
+
const section = (title: string, items: string[]): string => {
|
|
8
|
+
if (items.length === 0) return "";
|
|
9
|
+
const body = items.map((i) => `- ${i}`).join("\n");
|
|
10
|
+
return `[${title}]\n${body}`;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const BRIEF_MAX_LINES = 120;
|
|
14
|
+
|
|
15
|
+
export const capBrief = (text: string): string => {
|
|
16
|
+
const lines = text.split("\n");
|
|
17
|
+
if (lines.length <= BRIEF_MAX_LINES) return text;
|
|
18
|
+
const omitted = lines.length - BRIEF_MAX_LINES;
|
|
19
|
+
const kept = lines.slice(-BRIEF_MAX_LINES);
|
|
20
|
+
const firstHeader = kept.findIndex((l) => /^\[.+\]/.test(l));
|
|
21
|
+
const clean = firstHeader > 0 ? kept.slice(firstHeader) : kept;
|
|
22
|
+
return `...(${omitted} earlier lines omitted)\n\n${clean.join("\n")}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const RECALL_NOTE =
|
|
26
|
+
"Use `vcc_recall` to search for prior work, decisions, and context from before this summary. " +
|
|
27
|
+
"Do not redo work already completed.";
|
|
28
|
+
|
|
29
|
+
export const formatSummary = (data: SectionData): string => {
|
|
30
|
+
const headerParts = [
|
|
31
|
+
section("Session Goal", data.sessionGoal),
|
|
32
|
+
section("Files And Changes", data.filesAndChanges),
|
|
33
|
+
section("Commits", data.commits),
|
|
34
|
+
section("Outstanding Context", data.outstandingContext),
|
|
35
|
+
section("User Preferences", data.userPreferences),
|
|
36
|
+
].filter(Boolean);
|
|
37
|
+
|
|
38
|
+
const parts: string[] = [];
|
|
39
|
+
if (headerParts.length > 0) {
|
|
40
|
+
parts.push(headerParts.join("\n\n"));
|
|
41
|
+
}
|
|
42
|
+
if (data.briefTranscript) {
|
|
43
|
+
parts.push(capBrief(data.briefTranscript));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (parts.length === 0) return "";
|
|
47
|
+
return parts.join("\n\n---\n\n");
|
|
48
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook integration — session_before_compact + session_compact
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { convertToLlm } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
import { writeFileSync } from "node:fs";
|
|
8
|
+
import { compile } from "./summarize.js";
|
|
9
|
+
import { loadConfig } from "../config/manager.js";
|
|
10
|
+
import { buildOwnCut } from "./cut.js";
|
|
11
|
+
import type { CompactionStats } from "../types.js";
|
|
12
|
+
|
|
13
|
+
export const COMPACTOR_INSTRUCTION = "__compactor__";
|
|
14
|
+
|
|
15
|
+
let lastStats: CompactionStats | null = null;
|
|
16
|
+
let lastCompactWasCompactor = false;
|
|
17
|
+
export const getLastCompactionStats = () => lastStats;
|
|
18
|
+
|
|
19
|
+
const formatTokens = (n: number): string => {
|
|
20
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
21
|
+
return String(n);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const dbg = (debug: boolean, data: Record<string, unknown>) => {
|
|
25
|
+
if (!debug) return;
|
|
26
|
+
try { writeFileSync("/tmp/compactor-debug.json", JSON.stringify(data, null, 2)); } catch {}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const previewContent = (content: unknown): string => {
|
|
30
|
+
if (typeof content === "string") return content.slice(0, 300);
|
|
31
|
+
if (Array.isArray(content)) {
|
|
32
|
+
return content
|
|
33
|
+
.map((c: any) => {
|
|
34
|
+
if (c?.type === "text") return c.text ?? "";
|
|
35
|
+
if (c?.type === "toolCall") return `[toolCall:${c.name}]`;
|
|
36
|
+
if (c?.type === "thinking") return `[thinking]`;
|
|
37
|
+
if (c?.type === "image") return `[image:${c.mimeType}]`;
|
|
38
|
+
return `[${c?.type ?? "unknown"}]`;
|
|
39
|
+
})
|
|
40
|
+
.join("\n")
|
|
41
|
+
.slice(0, 300);
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const REASON_MESSAGES: Record<import("./cut.js").OwnCutCancelReason, string> = {
|
|
47
|
+
no_live_messages: "compactor: Nothing to compact (no live messages)",
|
|
48
|
+
too_few_live_messages: "compactor: Too few messages to compact",
|
|
49
|
+
no_user_message: "compactor: Cannot compact — no user message found",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function registerCompactionHooks(pi: ExtensionAPI): void {
|
|
53
|
+
pi.on("session_before_compact", (event, ctx) => {
|
|
54
|
+
const { preparation, branchEntries, customInstructions } = event;
|
|
55
|
+
const config = loadConfig();
|
|
56
|
+
|
|
57
|
+
const isCompactor = customInstructions === COMPACTOR_INSTRUCTION;
|
|
58
|
+
if (!isCompactor && !config.overrideDefaultCompaction) return;
|
|
59
|
+
|
|
60
|
+
const ownCut = buildOwnCut(branchEntries as any[]);
|
|
61
|
+
if (!ownCut.ok) {
|
|
62
|
+
try {
|
|
63
|
+
ctx?.ui?.notify?.(REASON_MESSAGES[ownCut.reason], "warning");
|
|
64
|
+
} catch {}
|
|
65
|
+
return { cancel: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const agentMessages = ownCut.messages;
|
|
69
|
+
const firstKeptEntryId = ownCut.firstKeptEntryId;
|
|
70
|
+
const messages = convertToLlm(agentMessages);
|
|
71
|
+
|
|
72
|
+
const keptIdx = (branchEntries as any[]).findIndex((e: any) => e.id === firstKeptEntryId);
|
|
73
|
+
const keptEntries = keptIdx >= 0
|
|
74
|
+
? (branchEntries as any[]).slice(keptIdx).filter((e: any) => e.type === "message")
|
|
75
|
+
: [];
|
|
76
|
+
const keptChars = keptEntries.reduce((sum: number, e: any) => {
|
|
77
|
+
const c = e.message?.content;
|
|
78
|
+
if (typeof c === "string") return sum + c.length;
|
|
79
|
+
if (Array.isArray(c)) return sum + c.reduce((s: number, p: any) => {
|
|
80
|
+
if (p.text) return s + p.text.length;
|
|
81
|
+
if (p.type === "toolCall") return s + (p.name?.length ?? 0) + (typeof p.input === "string" ? p.input.length : JSON.stringify(p.input ?? "").length);
|
|
82
|
+
if (p.type === "toolResult") return s + (typeof p.content === "string" ? p.content.length : JSON.stringify(p.content ?? "").length);
|
|
83
|
+
return s;
|
|
84
|
+
}, 0);
|
|
85
|
+
return sum;
|
|
86
|
+
}, 0);
|
|
87
|
+
lastStats = {
|
|
88
|
+
summarized: agentMessages.length,
|
|
89
|
+
kept: keptEntries.length,
|
|
90
|
+
keptTokensEst: Math.round(keptChars / 4),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const summary = compile({
|
|
94
|
+
messages,
|
|
95
|
+
previousSummary: preparation.previousSummary,
|
|
96
|
+
fileOps: {
|
|
97
|
+
readFiles: [...preparation.fileOps.read],
|
|
98
|
+
modifiedFiles: [...preparation.fileOps.written, ...preparation.fileOps.edited],
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
dbg(config.debug, {
|
|
103
|
+
usedOwnCut: true,
|
|
104
|
+
messagesToSummarize: agentMessages.length,
|
|
105
|
+
firstKeptEntryId,
|
|
106
|
+
tokensBefore: preparation.tokensBefore,
|
|
107
|
+
summaryLength: summary.length,
|
|
108
|
+
sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const details = {
|
|
112
|
+
compactor: "@pi-unipi/compactor",
|
|
113
|
+
version: 1,
|
|
114
|
+
sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
|
|
115
|
+
sourceMessageCount: agentMessages.length,
|
|
116
|
+
previousSummaryUsed: Boolean(preparation.previousSummary),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
lastCompactWasCompactor = isCompactor;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
compaction: {
|
|
123
|
+
summary,
|
|
124
|
+
details,
|
|
125
|
+
tokensBefore: preparation.tokensBefore,
|
|
126
|
+
firstKeptEntryId,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
pi.on("session_compact", (event, ctx) => {
|
|
132
|
+
if (!event.fromExtension) return;
|
|
133
|
+
if (lastCompactWasCompactor) return;
|
|
134
|
+
const stats = lastStats;
|
|
135
|
+
if (!stats) return;
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
try {
|
|
138
|
+
ctx?.ui?.notify?.(
|
|
139
|
+
`compactor: ${stats.summarized} source entries processed; tail kept ${stats.kept} (~${formatTokens(stats.keptTokensEst)} tok).`,
|
|
140
|
+
"info",
|
|
141
|
+
);
|
|
142
|
+
} catch {}
|
|
143
|
+
}, 500);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 6: Merge — Merge with previous summary, dedup, rolling window
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { capBrief } from "./format.js";
|
|
6
|
+
|
|
7
|
+
const HEADER_NAMES = ["Session Goal", "Files And Changes", "Commits", "Outstanding Context", "User Preferences"];
|
|
8
|
+
const SEPARATOR = "\n\n---\n\n";
|
|
9
|
+
|
|
10
|
+
const sectionOf = (text: string, header: string): string => {
|
|
11
|
+
const tag = `[${header}]`;
|
|
12
|
+
const start = text.indexOf(tag);
|
|
13
|
+
if (start < 0) return "";
|
|
14
|
+
const after = text.slice(start);
|
|
15
|
+
const nextSection = HEADER_NAMES
|
|
16
|
+
.filter((h) => h !== header)
|
|
17
|
+
.map((h) => after.indexOf(`[${h}]`))
|
|
18
|
+
.filter((n) => n > 0);
|
|
19
|
+
const nextSep = after.indexOf("\n\n---\n\n");
|
|
20
|
+
const candidates = [...nextSection, ...(nextSep > 0 ? [nextSep] : [])].sort((a, b) => a - b);
|
|
21
|
+
const end = candidates[0];
|
|
22
|
+
return (end ? after.slice(0, end) : after).trim();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const briefOf = (text: string): string => {
|
|
26
|
+
const idx = text.indexOf(SEPARATOR);
|
|
27
|
+
if (idx < 0) return "";
|
|
28
|
+
return text.slice(idx + SEPARATOR.length).trim();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const mergeHeaderSection = (header: string, prev: string, fresh: string): string => {
|
|
32
|
+
if (header === "Outstanding Context") return fresh;
|
|
33
|
+
if (!prev) return fresh;
|
|
34
|
+
if (!fresh) return prev;
|
|
35
|
+
|
|
36
|
+
if (header === "Files And Changes") {
|
|
37
|
+
return mergeFileLines(prev, fresh);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isClean = (l: string) => l.startsWith("- ") && !l.includes("<skill") && !l.includes("</skill");
|
|
41
|
+
const prevLines = prev.split("\n").filter(isClean);
|
|
42
|
+
const freshLines = fresh.split("\n").filter(isClean);
|
|
43
|
+
const combined = [...new Set([...prevLines, ...freshLines])];
|
|
44
|
+
const CAP = header === "Session Goal" ? 8 : header === "Commits" ? 8 : 15;
|
|
45
|
+
const capped = combined.length > CAP ? combined.slice(-CAP) : combined;
|
|
46
|
+
if (capped.length === 0) return "";
|
|
47
|
+
return `[${header}]\n${capped.join("\n")}`;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const mergeFileLines = (prev: string, fresh: string): string => {
|
|
51
|
+
const categories = ["Modified", "Created", "Read"] as const;
|
|
52
|
+
const merged: Record<string, Set<string>> = {};
|
|
53
|
+
for (const cat of categories) merged[cat] = new Set();
|
|
54
|
+
|
|
55
|
+
for (const text of [prev, fresh]) {
|
|
56
|
+
for (const line of text.split("\n")) {
|
|
57
|
+
for (const cat of categories) {
|
|
58
|
+
const prefix = `- ${cat}: `;
|
|
59
|
+
if (!line.startsWith(prefix)) continue;
|
|
60
|
+
let rest = line.slice(prefix.length).replace(/\s*\(\+\d+ more\)\s*$/, "");
|
|
61
|
+
for (const p of rest.split(",")) {
|
|
62
|
+
const trimmed = p.trim();
|
|
63
|
+
if (trimmed) merged[cat].add(trimmed);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const p of merged.Modified) merged.Created.delete(p);
|
|
70
|
+
|
|
71
|
+
const cap = (set: Set<string>, limit: number) => {
|
|
72
|
+
const arr = [...set];
|
|
73
|
+
if (arr.length <= limit) return arr.join(", ");
|
|
74
|
+
return arr.slice(0, limit).join(", ") + ` (+${arr.length - limit} more)`;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const lines: string[] = [];
|
|
78
|
+
if (merged.Modified.size > 0) lines.push(`- Modified: ${cap(merged.Modified, 10)}`);
|
|
79
|
+
if (merged.Created.size > 0) lines.push(`- Created: ${cap(merged.Created, 10)}`);
|
|
80
|
+
if (merged.Read.size > 0) lines.push(`- Read: ${cap(merged.Read, 10)}`);
|
|
81
|
+
if (lines.length === 0) return "";
|
|
82
|
+
return `[Files And Changes]\n${lines.join("\n")}`;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const mergeBriefTranscript = (prev: string, fresh: string): string => {
|
|
86
|
+
if (!prev) return fresh;
|
|
87
|
+
if (!fresh) return prev;
|
|
88
|
+
return prev + "\n\n" + fresh;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const mergePrevious = (prev: string, fresh: string): string => {
|
|
92
|
+
const headers = HEADER_NAMES
|
|
93
|
+
.map((header) => {
|
|
94
|
+
const freshSec = sectionOf(fresh, header);
|
|
95
|
+
const prevSec = sectionOf(prev, header);
|
|
96
|
+
return mergeHeaderSection(header, prevSec, freshSec);
|
|
97
|
+
})
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
|
|
100
|
+
const prevBrief = briefOf(prev);
|
|
101
|
+
const freshBrief = briefOf(fresh);
|
|
102
|
+
const mergedBrief = mergeBriefTranscript(prevBrief, freshBrief);
|
|
103
|
+
|
|
104
|
+
const parts: string[] = [];
|
|
105
|
+
if (headers.length > 0) {
|
|
106
|
+
parts.push(headers.join("\n\n"));
|
|
107
|
+
}
|
|
108
|
+
if (mergedBrief) {
|
|
109
|
+
parts.push(capBrief(mergedBrief));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return parts.join(SEPARATOR);
|
|
113
|
+
};
|