@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.
Files changed (65) hide show
  1. package/README.md +86 -0
  2. package/package.json +54 -0
  3. package/skills/compactor/SKILL.md +74 -0
  4. package/skills/compactor-doctor/SKILL.md +74 -0
  5. package/skills/compactor-ops/SKILL.md +65 -0
  6. package/skills/compactor-stats/SKILL.md +49 -0
  7. package/skills/compactor-tools/SKILL.md +120 -0
  8. package/src/commands/index.ts +248 -0
  9. package/src/compaction/brief.ts +334 -0
  10. package/src/compaction/build-sections.ts +77 -0
  11. package/src/compaction/content.ts +47 -0
  12. package/src/compaction/cut.ts +80 -0
  13. package/src/compaction/extract/commits.ts +52 -0
  14. package/src/compaction/extract/files.ts +58 -0
  15. package/src/compaction/extract/goals.ts +36 -0
  16. package/src/compaction/extract/preferences.ts +40 -0
  17. package/src/compaction/filter-noise.ts +46 -0
  18. package/src/compaction/format.ts +48 -0
  19. package/src/compaction/hooks.ts +145 -0
  20. package/src/compaction/merge.ts +113 -0
  21. package/src/compaction/normalize.ts +68 -0
  22. package/src/compaction/recall-scope.ts +32 -0
  23. package/src/compaction/sanitize.ts +12 -0
  24. package/src/compaction/search-entries.ts +101 -0
  25. package/src/compaction/sections.ts +15 -0
  26. package/src/compaction/summarize.ts +29 -0
  27. package/src/config/manager.ts +89 -0
  28. package/src/config/presets.ts +83 -0
  29. package/src/config/schema.ts +55 -0
  30. package/src/display/bash-display.ts +28 -0
  31. package/src/display/diff-presentation.ts +20 -0
  32. package/src/display/diff-renderer.ts +255 -0
  33. package/src/display/line-width-safety.ts +16 -0
  34. package/src/display/pending-diff-preview.ts +51 -0
  35. package/src/display/render-utils.ts +52 -0
  36. package/src/display/thinking-label.ts +18 -0
  37. package/src/display/tool-overrides.ts +136 -0
  38. package/src/display/user-message-box.ts +16 -0
  39. package/src/executor/executor.ts +242 -0
  40. package/src/executor/runtime.ts +125 -0
  41. package/src/index.ts +211 -0
  42. package/src/info-screen.ts +60 -0
  43. package/src/security/evaluator.ts +142 -0
  44. package/src/security/policy.ts +74 -0
  45. package/src/security/scanner.ts +65 -0
  46. package/src/session/db.ts +237 -0
  47. package/src/session/extract.ts +107 -0
  48. package/src/session/resume-inject.ts +25 -0
  49. package/src/session/snapshot.ts +326 -0
  50. package/src/store/chunking.ts +126 -0
  51. package/src/store/db-base.ts +79 -0
  52. package/src/store/index.ts +364 -0
  53. package/src/tools/compact.ts +20 -0
  54. package/src/tools/ctx-batch-execute.ts +53 -0
  55. package/src/tools/ctx-doctor.ts +78 -0
  56. package/src/tools/ctx-execute-file.ts +26 -0
  57. package/src/tools/ctx-execute.ts +21 -0
  58. package/src/tools/ctx-fetch-and-index.ts +37 -0
  59. package/src/tools/ctx-index.ts +42 -0
  60. package/src/tools/ctx-search.ts +23 -0
  61. package/src/tools/ctx-stats.ts +37 -0
  62. package/src/tools/register.ts +360 -0
  63. package/src/tools/vcc-recall.ts +64 -0
  64. package/src/tui/settings-overlay.ts +290 -0
  65. package/src/types.ts +269 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Stage 1: Normalize — Message[] → NormalizedBlock[]
3
+ */
4
+
5
+ import type { Message } from "@mariozechner/pi-ai";
6
+ import type { NormalizedBlock } from "../types.js";
7
+ import { textOf } from "./content.js";
8
+ import { sanitize } from "./sanitize.js";
9
+
10
+ const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
11
+ if (msg.role === "user") {
12
+ const blocks: NormalizedBlock[] = [];
13
+ const text = sanitize(textOf(msg.content));
14
+ if (text) blocks.push({ kind: "user", text, sourceIndex: msgIndex });
15
+ if (msg.content && typeof msg.content !== "string") {
16
+ for (const part of msg.content) {
17
+ if (part.type === "image") {
18
+ blocks.push({ kind: "user", text: `[image: ${part.mimeType}]`, sourceIndex: msgIndex });
19
+ }
20
+ }
21
+ }
22
+ return blocks.length > 0 ? blocks : [{ kind: "user", text: "", sourceIndex: msgIndex }];
23
+ }
24
+
25
+ if (msg.role === "toolResult") {
26
+ return [{
27
+ kind: "tool_result",
28
+ name: msg.toolName,
29
+ text: sanitize(textOf(msg.content)),
30
+ isError: msg.isError,
31
+ sourceIndex: msgIndex,
32
+ }];
33
+ }
34
+
35
+ if (msg.role === "assistant") {
36
+ if (!msg.content) return [];
37
+ if (typeof msg.content === "string") {
38
+ return [{ kind: "assistant", text: sanitize(msg.content), sourceIndex: msgIndex }];
39
+ }
40
+
41
+ const blocks: NormalizedBlock[] = [];
42
+ for (const part of msg.content) {
43
+ if (part.type === "text") {
44
+ blocks.push({ kind: "assistant", text: sanitize(part.text), sourceIndex: msgIndex });
45
+ } else if (part.type === "thinking") {
46
+ blocks.push({
47
+ kind: "thinking",
48
+ text: sanitize(part.thinking),
49
+ redacted: part.redacted ?? false,
50
+ sourceIndex: msgIndex,
51
+ });
52
+ } else if (part.type === "toolCall") {
53
+ blocks.push({
54
+ kind: "tool_call",
55
+ name: part.name,
56
+ args: part.arguments,
57
+ sourceIndex: msgIndex,
58
+ });
59
+ }
60
+ }
61
+ return blocks;
62
+ }
63
+
64
+ return [];
65
+ };
66
+
67
+ export const normalizeMessages = (messages: Message[]): NormalizedBlock[] =>
68
+ messages.flatMap((msg, i) => normalizeOne(msg, i));
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Recall scope — lineage filtering for vcc_recall
3
+ */
4
+
5
+ export interface LineageRange {
6
+ startIndex: number;
7
+ endIndex: number;
8
+ }
9
+
10
+ /**
11
+ * Determine the valid message index range for recall based on
12
+ * the most recent compaction boundary.
13
+ */
14
+ export function getRecallScope(
15
+ branchEntries: any[],
16
+ opts?: { expand?: boolean },
17
+ ): LineageRange {
18
+ let lastCompactionIdx = -1;
19
+ for (let i = branchEntries.length - 1; i >= 0; i--) {
20
+ if (branchEntries[i].type === "compaction") {
21
+ lastCompactionIdx = i;
22
+ break;
23
+ }
24
+ }
25
+
26
+ // If expand is true, include everything; otherwise only post-compaction
27
+ const startIndex = opts?.expand ? 0 : lastCompactionIdx + 1;
28
+ return {
29
+ startIndex: Math.max(0, startIndex),
30
+ endIndex: branchEntries.length,
31
+ };
32
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Sanitize message text — strip control chars, normalize whitespace
3
+ */
4
+
5
+ export function sanitize(text: string): string {
6
+ if (!text) return "";
7
+ return text
8
+ .replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f]/g, "")
9
+ .replace(/\r\n/g, "\n")
10
+ .replace(/\s+/g, " ")
11
+ .trim();
12
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * BM25-lite search over normalized message blocks
3
+ */
4
+
5
+ import type { NormalizedBlock } from "../types.js";
6
+
7
+ interface SearchDoc {
8
+ id: number;
9
+ text: string;
10
+ kind: string;
11
+ }
12
+
13
+ function tokenize(text: string): string[] {
14
+ return text
15
+ .toLowerCase()
16
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
17
+ .split(/\s+/)
18
+ .filter((w) => w.length > 1);
19
+ }
20
+
21
+ function buildIndex(docs: SearchDoc[]): Map<string, number[]> {
22
+ const index = new Map<string, number[]>();
23
+ for (const doc of docs) {
24
+ const tokens = new Set(tokenize(doc.text));
25
+ for (const t of tokens) {
26
+ const arr = index.get(t) ?? [];
27
+ arr.push(doc.id);
28
+ index.set(t, arr);
29
+ }
30
+ }
31
+ return index;
32
+ }
33
+
34
+ function bm25Score(
35
+ queryTokens: string[],
36
+ docId: number,
37
+ index: Map<string, number[]>,
38
+ docCount: number,
39
+ avgDocLen: number,
40
+ docLens: Map<number, number>,
41
+ ): number {
42
+ const k1 = 1.5;
43
+ const b = 0.75;
44
+ let score = 0;
45
+ const docLen = docLens.get(docId) ?? 1;
46
+
47
+ for (const token of queryTokens) {
48
+ const postings = index.get(token) ?? [];
49
+ const df = new Set(postings).size;
50
+ if (df === 0) continue;
51
+ const tf = postings.filter((id) => id === docId).length;
52
+ const idf = Math.log((docCount - df + 0.5) / (df + 0.5) + 1);
53
+ score += idf * ((tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (docLen / avgDocLen))));
54
+ }
55
+
56
+ return score;
57
+ }
58
+
59
+ export interface SearchHit {
60
+ docId: number;
61
+ score: number;
62
+ text: string;
63
+ kind: string;
64
+ }
65
+
66
+ export function searchEntries(
67
+ blocks: NormalizedBlock[],
68
+ query: string,
69
+ opts?: { limit?: number; offset?: number },
70
+ ): SearchHit[] {
71
+ const docs: SearchDoc[] = blocks.map((b, i) => ({
72
+ id: i,
73
+ text: b.kind === "tool_call" ? `${b.name} ${JSON.stringify(b.args)}` : b.kind === "tool_result" ? `${b.name} ${b.text}` : b.text,
74
+ kind: b.kind,
75
+ }));
76
+
77
+ const index = buildIndex(docs);
78
+ const docCount = docs.length;
79
+ const docLens = new Map(docs.map((d) => [d.id, tokenize(d.text).length]));
80
+ const avgDocLen = docCount > 0 ? [...docLens.values()].reduce((a, b) => a + b, 0) / docCount : 1;
81
+
82
+ const queryTokens = tokenize(query);
83
+ if (queryTokens.length === 0) return [];
84
+
85
+ const scores = new Map<number, number>();
86
+ for (const doc of docs) {
87
+ const score = bm25Score(queryTokens, doc.id, index, docCount, avgDocLen, docLens);
88
+ if (score > 0) scores.set(doc.id, score);
89
+ }
90
+
91
+ const sorted = [...scores.entries()]
92
+ .sort((a, b) => b[1] - a[1])
93
+ .map(([docId, score]) => {
94
+ const doc = docs[docId];
95
+ return { docId, score, text: doc.text.slice(0, 300), kind: doc.kind };
96
+ });
97
+
98
+ const offset = opts?.offset ?? 0;
99
+ const limit = opts?.limit ?? 10;
100
+ return sorted.slice(offset, offset + limit);
101
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Section data type for compaction pipeline
3
+ */
4
+
5
+ import type { TranscriptEntry } from "../types.js";
6
+
7
+ export interface SectionData {
8
+ sessionGoal: string[];
9
+ filesAndChanges: string[];
10
+ commits: string[];
11
+ outstandingContext: string[];
12
+ userPreferences: string[];
13
+ briefTranscript: string;
14
+ transcriptEntries: TranscriptEntry[];
15
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Main compile() orchestrator — all 6 stages
3
+ */
4
+
5
+ import type { Message } from "@mariozechner/pi-ai";
6
+ import type { CompileInput, FileOps } from "../types.js";
7
+ import { normalizeMessages } from "./normalize.js";
8
+ import { filterNoise } from "./filter-noise.js";
9
+ import { buildSections } from "./build-sections.js";
10
+ import { formatSummary, RECALL_NOTE } from "./format.js";
11
+ import { mergePrevious } from "./merge.js";
12
+
13
+ export const compile = (input: CompileInput): string => {
14
+ const blocks = filterNoise(normalizeMessages(input.messages));
15
+ const data = buildSections({ blocks });
16
+ const fresh = formatSummary(data);
17
+ const prev = input.previousSummary
18
+ ? stripRecallNote(input.previousSummary)
19
+ : undefined;
20
+ const merged = prev ? mergePrevious(prev, fresh) : fresh;
21
+ if (!merged) return "";
22
+ return merged + "\n\n---\n\n" + RECALL_NOTE;
23
+ };
24
+
25
+ const stripRecallNote = (text: string): string => {
26
+ const idx = text.lastIndexOf(RECALL_NOTE);
27
+ if (idx < 0) return text;
28
+ return text.slice(0, idx).replace(/\s*(?:\n\n---\n\n)?\s*$/, "").trimEnd();
29
+ };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Config manager — load, save, migrate compactor settings
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join } from "node:path";
8
+ import type { CompactorConfig } from "../types.js";
9
+ import { DEFAULT_COMPACTOR_CONFIG } from "./schema.js";
10
+
11
+ export const COMPACTOR_CONFIG_PATH = join(homedir(), ".unipi", "config", "compactor", "config.json");
12
+
13
+ const readJson = (path: string): Record<string, unknown> | null => {
14
+ try {
15
+ return JSON.parse(readFileSync(path, "utf-8"));
16
+ } catch {
17
+ return null;
18
+ }
19
+ };
20
+
21
+ /**
22
+ * Load compactor config from disk with defaults fallback.
23
+ */
24
+ export function loadConfig(): CompactorConfig {
25
+ const parsed = readJson(COMPACTOR_CONFIG_PATH);
26
+ if (!parsed || typeof parsed !== "object") return structuredClone(DEFAULT_COMPACTOR_CONFIG);
27
+ return migrateConfig(parsed as Partial<CompactorConfig>);
28
+ }
29
+
30
+ /**
31
+ * Save config to disk with schema validation.
32
+ */
33
+ export function saveConfig(config: CompactorConfig): { success: boolean; error?: string } {
34
+ try {
35
+ const dir = dirname(COMPACTOR_CONFIG_PATH);
36
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
37
+ writeFileSync(COMPACTOR_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`);
38
+ return { success: true };
39
+ } catch (err) {
40
+ return { success: false, error: String(err) };
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Migrate partial config to full schema, filling missing keys from defaults.
46
+ */
47
+ export function migrateConfig(partial: Partial<CompactorConfig>): CompactorConfig {
48
+ const defaults = structuredClone(DEFAULT_COMPACTOR_CONFIG);
49
+
50
+ function mergeStrategy<K extends keyof CompactorConfig>(
51
+ key: K,
52
+ defaultVal: CompactorConfig[K],
53
+ partialVal: CompactorConfig[K] | undefined,
54
+ ): CompactorConfig[K] {
55
+ if (!partialVal || typeof partialVal !== "object") return defaultVal;
56
+ return { ...(defaultVal as any), ...(partialVal as any) };
57
+ }
58
+
59
+ return {
60
+ sessionGoals: mergeStrategy("sessionGoals", defaults.sessionGoals, partial.sessionGoals),
61
+ filesAndChanges: mergeStrategy("filesAndChanges", defaults.filesAndChanges, partial.filesAndChanges),
62
+ commits: mergeStrategy("commits", defaults.commits, partial.commits),
63
+ outstandingContext: mergeStrategy("outstandingContext", defaults.outstandingContext, partial.outstandingContext),
64
+ userPreferences: mergeStrategy("userPreferences", defaults.userPreferences, partial.userPreferences),
65
+ briefTranscript: mergeStrategy("briefTranscript", defaults.briefTranscript, partial.briefTranscript),
66
+ sessionContinuity: mergeStrategy("sessionContinuity", defaults.sessionContinuity, partial.sessionContinuity),
67
+ fts5Index: mergeStrategy("fts5Index", defaults.fts5Index, partial.fts5Index),
68
+ sandboxExecution: mergeStrategy("sandboxExecution", defaults.sandboxExecution, partial.sandboxExecution),
69
+ toolDisplay: mergeStrategy("toolDisplay", defaults.toolDisplay, partial.toolDisplay),
70
+ overrideDefaultCompaction: partial.overrideDefaultCompaction ?? defaults.overrideDefaultCompaction,
71
+ debug: partial.debug ?? defaults.debug,
72
+ showTruncationHints: partial.showTruncationHints ?? defaults.showTruncationHints,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Scaffold config file on first run.
78
+ */
79
+ export function scaffoldConfig(): void {
80
+ try {
81
+ const dir = dirname(COMPACTOR_CONFIG_PATH);
82
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
83
+ if (!existsSync(COMPACTOR_CONFIG_PATH)) {
84
+ writeFileSync(COMPACTOR_CONFIG_PATH, `${JSON.stringify(DEFAULT_COMPACTOR_CONFIG, null, 2)}\n`);
85
+ }
86
+ } catch {
87
+ // best-effort
88
+ }
89
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Preset definitions + detection for compactor config
3
+ */
4
+
5
+ import type { CompactorConfig, CompactorPreset } from "../types.js";
6
+ import { DEFAULT_COMPACTOR_CONFIG } from "./schema.js";
7
+
8
+ const preset = (
9
+ overrides: Partial<CompactorConfig>,
10
+ ): CompactorConfig => ({
11
+ ...structuredClone(DEFAULT_COMPACTOR_CONFIG),
12
+ ...overrides,
13
+ sessionGoals: { ...DEFAULT_COMPACTOR_CONFIG.sessionGoals, ...(overrides.sessionGoals as any) },
14
+ filesAndChanges: { ...DEFAULT_COMPACTOR_CONFIG.filesAndChanges, ...(overrides.filesAndChanges as any) },
15
+ commits: { ...DEFAULT_COMPACTOR_CONFIG.commits, ...(overrides.commits as any) },
16
+ outstandingContext: { ...DEFAULT_COMPACTOR_CONFIG.outstandingContext, ...(overrides.outstandingContext as any) },
17
+ userPreferences: { ...DEFAULT_COMPACTOR_CONFIG.userPreferences, ...(overrides.userPreferences as any) },
18
+ briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, ...(overrides.briefTranscript as any) },
19
+ sessionContinuity: { ...DEFAULT_COMPACTOR_CONFIG.sessionContinuity, ...(overrides.sessionContinuity as any) },
20
+ fts5Index: { ...DEFAULT_COMPACTOR_CONFIG.fts5Index, ...(overrides.fts5Index as any) },
21
+ sandboxExecution: { ...DEFAULT_COMPACTOR_CONFIG.sandboxExecution, ...(overrides.sandboxExecution as any) },
22
+ toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, ...(overrides.toolDisplay as any) },
23
+ });
24
+
25
+ export const PRESET_CONFIGS: Record<CompactorPreset, CompactorConfig> = {
26
+ opencode: preset({
27
+ toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, mode: "opencode" },
28
+ }),
29
+ balanced: preset({
30
+ briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, mode: "compact" },
31
+ toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, mode: "balanced" },
32
+ fts5Index: { ...DEFAULT_COMPACTOR_CONFIG.fts5Index, mode: "auto" },
33
+ }),
34
+ verbose: preset({
35
+ briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, mode: "full" },
36
+ toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, mode: "verbose" },
37
+ }),
38
+ minimal: preset({
39
+ sessionGoals: { ...DEFAULT_COMPACTOR_CONFIG.sessionGoals, enabled: true, mode: "brief" },
40
+ filesAndChanges: { ...DEFAULT_COMPACTOR_CONFIG.filesAndChanges, enabled: true, mode: "modified-only" },
41
+ commits: { ...DEFAULT_COMPACTOR_CONFIG.commits, enabled: false, mode: "off" },
42
+ outstandingContext: { ...DEFAULT_COMPACTOR_CONFIG.outstandingContext, enabled: true, mode: "critical-only" },
43
+ userPreferences: { ...DEFAULT_COMPACTOR_CONFIG.userPreferences, enabled: false, mode: "off" },
44
+ briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, enabled: true, mode: "minimal" },
45
+ sessionContinuity: { ...DEFAULT_COMPACTOR_CONFIG.sessionContinuity, enabled: false, mode: "off" },
46
+ fts5Index: { ...DEFAULT_COMPACTOR_CONFIG.fts5Index, enabled: false, mode: "off" },
47
+ sandboxExecution: { ...DEFAULT_COMPACTOR_CONFIG.sandboxExecution, enabled: false, mode: "off" },
48
+ toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, enabled: true, mode: "opencode" },
49
+ }),
50
+ custom: structuredClone(DEFAULT_COMPACTOR_CONFIG),
51
+ };
52
+
53
+ function configsEqual(a: CompactorConfig, b: CompactorConfig): boolean {
54
+ return JSON.stringify(a) === JSON.stringify(b);
55
+ }
56
+
57
+ /**
58
+ * Detect which preset a config matches, or "custom".
59
+ */
60
+ export function detectPreset(config: CompactorConfig): CompactorPreset {
61
+ for (const name of ["opencode", "balanced", "verbose", "minimal"] as const) {
62
+ if (configsEqual(config, PRESET_CONFIGS[name])) return name;
63
+ }
64
+ return "custom";
65
+ }
66
+
67
+ /**
68
+ * Apply a preset to a config.
69
+ */
70
+ export function applyPreset(name: CompactorPreset): CompactorConfig {
71
+ return structuredClone(PRESET_CONFIGS[name]);
72
+ }
73
+
74
+ /**
75
+ * Parse a preset name (case-insensitive).
76
+ */
77
+ export function parsePreset(raw: string): CompactorPreset | undefined {
78
+ const normalized = raw.trim().toLowerCase();
79
+ if (normalized === "opencode" || normalized === "balanced" || normalized === "verbose" || normalized === "minimal" || normalized === "custom") {
80
+ return normalized;
81
+ }
82
+ return undefined;
83
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Compactor configuration schema with defaults
3
+ */
4
+
5
+ import type { CompactorConfig, CompactorStrategyConfig } from "../types.js";
6
+
7
+ const strategy = (enabled: boolean, mode: string): CompactorStrategyConfig => ({
8
+ enabled,
9
+ mode,
10
+ });
11
+
12
+ export const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = {
13
+ sessionGoals: { ...strategy(true, "full"), mode: "full" },
14
+ filesAndChanges: { ...strategy(true, "all"), mode: "all", maxPerCategory: 10 },
15
+ commits: { ...strategy(true, "full"), mode: "full", maxCommits: 8 },
16
+ outstandingContext: { ...strategy(true, "full"), mode: "full", maxItems: 5 },
17
+ userPreferences: { ...strategy(true, "all"), mode: "all", maxPreferences: 10 },
18
+ briefTranscript: {
19
+ ...strategy(true, "full"),
20
+ mode: "full",
21
+ userTokenLimit: 256,
22
+ assistantTokenLimit: 200,
23
+ toolCallLimit: 8,
24
+ },
25
+ sessionContinuity: {
26
+ ...strategy(true, "full"),
27
+ mode: "full",
28
+ eventCategories: [],
29
+ },
30
+ fts5Index: {
31
+ ...strategy(true, "manual"),
32
+ mode: "manual",
33
+ chunkSize: 4096,
34
+ cacheTtlHours: 24,
35
+ },
36
+ sandboxExecution: {
37
+ ...strategy(true, "all"),
38
+ mode: "all",
39
+ allowedLanguages: ["javascript", "typescript", "python", "shell"],
40
+ outputLimit: 100 * 1024 * 1024,
41
+ },
42
+ toolDisplay: {
43
+ ...strategy(true, "opencode"),
44
+ mode: "opencode",
45
+ diffLayout: "auto",
46
+ diffIndicator: "bars",
47
+ showThinkingLabels: true,
48
+ showUserMessageBox: true,
49
+ showBashSpinner: true,
50
+ showPendingPreviews: true,
51
+ },
52
+ overrideDefaultCompaction: false,
53
+ debug: false,
54
+ showTruncationHints: true,
55
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Bash display — spinner + elapsed time
3
+ */
4
+
5
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+
7
+ export function getSpinnerFrame(index: number): string {
8
+ return SPINNER_FRAMES[index % SPINNER_FRAMES.length];
9
+ }
10
+
11
+ export function formatElapsedTime(ms: number): string {
12
+ if (ms < 1000) return `${ms}ms`;
13
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
14
+ const mins = Math.floor(ms / 60000);
15
+ const secs = ((ms % 60000) / 1000).toFixed(0);
16
+ return `${mins}m${secs}s`;
17
+ }
18
+
19
+ export function renderBashCall(command: string, opts?: { collapsed?: boolean }): string {
20
+ if (opts?.collapsed) {
21
+ const firstLine = command.split("\n")[0]?.trim() ?? command;
22
+ if (firstLine.length > 80) {
23
+ return firstLine.slice(0, 77) + "...";
24
+ }
25
+ return firstLine;
26
+ }
27
+ return command;
28
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Diff presentation — layout selection based on terminal width
3
+ */
4
+
5
+ import type { DiffLayout } from "../types.js";
6
+
7
+ export function selectDiffLayout(
8
+ terminalWidth: number,
9
+ preferred: DiffLayout = "auto",
10
+ ): "split" | "unified" {
11
+ if (preferred !== "auto") return preferred;
12
+ return terminalWidth >= 100 ? "split" : "unified";
13
+ }
14
+
15
+ export function clampWidth(text: string, maxWidth: number): string {
16
+ return text
17
+ .split("\n")
18
+ .map((line) => (line.length > maxWidth ? line.slice(0, maxWidth - 3) + "..." : line))
19
+ .join("\n");
20
+ }