@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,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security evaluator — command + file path evaluation
|
|
3
|
+
*
|
|
4
|
+
* Supports loading permission patterns from .pi/settings.json
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { realpathSync, existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { resolve, join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import type { PermissionDecision, SecurityPolicy } from "./policy.js";
|
|
11
|
+
import { parseBashPattern, parseToolPattern, globToRegex, fileGlobToRegex } from "./policy.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load permission patterns from .pi/settings.json in the given directory.
|
|
15
|
+
* Returns a SecurityPolicy merged with the provided policy.
|
|
16
|
+
*/
|
|
17
|
+
export function loadProjectPermissions(
|
|
18
|
+
cwd: string,
|
|
19
|
+
basePolicy: SecurityPolicy,
|
|
20
|
+
): SecurityPolicy {
|
|
21
|
+
const settingsPath = join(cwd, ".pi", "settings.json");
|
|
22
|
+
if (!existsSync(settingsPath)) return basePolicy;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const raw = readFileSync(settingsPath, "utf-8");
|
|
26
|
+
const settings = JSON.parse(raw);
|
|
27
|
+
const permissions = settings.permissions ?? settings.security ?? {};
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
deny: [...basePolicy.deny, ...(permissions.deny ?? [])],
|
|
31
|
+
ask: [...basePolicy.ask, ...(permissions.ask ?? [])],
|
|
32
|
+
allow: [...basePolicy.allow, ...(permissions.allow ?? [])],
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
return basePolicy;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function evaluateCommand(
|
|
40
|
+
command: string,
|
|
41
|
+
policy: SecurityPolicy,
|
|
42
|
+
): PermissionDecision {
|
|
43
|
+
// Deny takes highest precedence
|
|
44
|
+
for (const pattern of policy.deny) {
|
|
45
|
+
const bashGlob = parseBashPattern(pattern);
|
|
46
|
+
if (bashGlob && globToRegex(bashGlob, true).test(command)) return "deny";
|
|
47
|
+
const toolPattern = parseToolPattern(pattern);
|
|
48
|
+
if (toolPattern && toolPattern.tool === "Bash" && globToRegex(toolPattern.glob, true).test(command)) {
|
|
49
|
+
return "deny";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Ask patterns
|
|
54
|
+
for (const pattern of policy.ask) {
|
|
55
|
+
const bashGlob = parseBashPattern(pattern);
|
|
56
|
+
if (bashGlob && globToRegex(bashGlob, true).test(command)) return "ask";
|
|
57
|
+
const toolPattern = parseToolPattern(pattern);
|
|
58
|
+
if (toolPattern && toolPattern.tool === "Bash" && globToRegex(toolPattern.glob, true).test(command)) {
|
|
59
|
+
return "ask";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Allow patterns
|
|
64
|
+
for (const pattern of policy.allow) {
|
|
65
|
+
const bashGlob = parseBashPattern(pattern);
|
|
66
|
+
if (bashGlob && globToRegex(bashGlob, true).test(command)) return "allow";
|
|
67
|
+
const toolPattern = parseToolPattern(pattern);
|
|
68
|
+
if (toolPattern && toolPattern.tool === "Bash" && globToRegex(toolPattern.glob, true).test(command)) {
|
|
69
|
+
return "allow";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return "allow";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function splitChainedCommands(command: string): string[] {
|
|
77
|
+
const commands: string[] = [];
|
|
78
|
+
let current = "";
|
|
79
|
+
let inQuotes = false;
|
|
80
|
+
let quoteChar = "";
|
|
81
|
+
|
|
82
|
+
for (let i = 0; i < command.length; i++) {
|
|
83
|
+
const char = command[i];
|
|
84
|
+
|
|
85
|
+
if (!inQuotes && (char === '"' || char === "'" || char === "`")) {
|
|
86
|
+
inQuotes = true;
|
|
87
|
+
quoteChar = char;
|
|
88
|
+
current += char;
|
|
89
|
+
} else if (inQuotes && char === quoteChar) {
|
|
90
|
+
inQuotes = false;
|
|
91
|
+
quoteChar = "";
|
|
92
|
+
current += char;
|
|
93
|
+
} else if (!inQuotes && (char === "&" || char === "|" || char === ";")) {
|
|
94
|
+
if (current.trim()) commands.push(current.trim());
|
|
95
|
+
current = "";
|
|
96
|
+
// Skip the next char if it's part of && or ||
|
|
97
|
+
if ((char === "&" || char === "|") && command[i + 1] === char) i++;
|
|
98
|
+
} else {
|
|
99
|
+
current += char;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (current.trim()) commands.push(current.trim());
|
|
104
|
+
return commands;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function evaluateFilePath(
|
|
108
|
+
filePath: string,
|
|
109
|
+
policy: SecurityPolicy,
|
|
110
|
+
cwd: string = process.cwd(),
|
|
111
|
+
): PermissionDecision {
|
|
112
|
+
const resolved = resolve(cwd, filePath);
|
|
113
|
+
|
|
114
|
+
// Prevent symlink escape
|
|
115
|
+
try {
|
|
116
|
+
const real = realpathSync(resolved);
|
|
117
|
+
const home = homedir();
|
|
118
|
+
if (!real.startsWith(cwd) && !real.startsWith(home)) {
|
|
119
|
+
return "deny";
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// File doesn't exist yet — check path pattern
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const pattern of policy.deny) {
|
|
126
|
+
const toolPattern = parseToolPattern(pattern);
|
|
127
|
+
if (!toolPattern) continue;
|
|
128
|
+
if (["Read", "Edit", "Write", "read", "edit", "write"].includes(toolPattern.tool)) {
|
|
129
|
+
if (fileGlobToRegex(toolPattern.glob, true).test(resolved)) return "deny";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const pattern of policy.ask) {
|
|
134
|
+
const toolPattern = parseToolPattern(pattern);
|
|
135
|
+
if (!toolPattern) continue;
|
|
136
|
+
if (["Read", "Edit", "Write", "read", "edit", "write"].includes(toolPattern.tool)) {
|
|
137
|
+
if (fileGlobToRegex(toolPattern.glob, true).test(resolved)) return "ask";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return "allow";
|
|
142
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security policy — pattern parsing, glob-to-regex
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type PermissionDecision = "allow" | "deny" | "ask";
|
|
6
|
+
|
|
7
|
+
export interface SecurityPolicy {
|
|
8
|
+
allow: string[];
|
|
9
|
+
deny: string[];
|
|
10
|
+
ask: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseBashPattern(pattern: string): string | null {
|
|
14
|
+
const match = pattern.match(/^Bash\((.+)\)$/);
|
|
15
|
+
return match ? match[1] : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseToolPattern(pattern: string): { tool: string; glob: string } | null {
|
|
19
|
+
const match = pattern.match(/^(\w+)\((.+)\)$/);
|
|
20
|
+
return match ? { tool: match[1], glob: match[2] } : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function escapeRegex(str: string): string {
|
|
24
|
+
return str.replace(/[.*+?^${}()|[\]\\/\-]/g, "\\$&");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function convertGlobPart(glob: string): string {
|
|
28
|
+
return glob
|
|
29
|
+
.replace(/[.+?^${}()|[\]\\/\-]/g, "\\$&")
|
|
30
|
+
.replace(/\*/g, ".*");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function globToRegex(glob: string, caseInsensitive: boolean = false): RegExp {
|
|
34
|
+
let regexStr: string;
|
|
35
|
+
const colonIdx = glob.indexOf(":");
|
|
36
|
+
if (colonIdx !== -1) {
|
|
37
|
+
const command = glob.slice(0, colonIdx);
|
|
38
|
+
const argsGlob = glob.slice(colonIdx + 1);
|
|
39
|
+
const escapedCmd = escapeRegex(command);
|
|
40
|
+
const argsRegex = convertGlobPart(argsGlob);
|
|
41
|
+
regexStr = `^${escapedCmd}(\\s${argsRegex})?$`;
|
|
42
|
+
} else {
|
|
43
|
+
regexStr = `^${convertGlobPart(glob)}$`;
|
|
44
|
+
}
|
|
45
|
+
return new RegExp(regexStr, caseInsensitive ? "i" : "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function fileGlobToRegex(glob: string, caseInsensitive: boolean = false): RegExp {
|
|
49
|
+
let regexStr = "";
|
|
50
|
+
let i = 0;
|
|
51
|
+
|
|
52
|
+
while (i < glob.length) {
|
|
53
|
+
if (glob[i] === "*" && glob[i + 1] === "*") {
|
|
54
|
+
if (i + 2 < glob.length && glob[i + 2] === "/") {
|
|
55
|
+
regexStr += "(.*/)?";
|
|
56
|
+
i += 3;
|
|
57
|
+
} else {
|
|
58
|
+
regexStr += ".*";
|
|
59
|
+
i += 2;
|
|
60
|
+
}
|
|
61
|
+
} else if (glob[i] === "*") {
|
|
62
|
+
regexStr += "[^/]*";
|
|
63
|
+
i++;
|
|
64
|
+
} else if (glob[i] === "?") {
|
|
65
|
+
regexStr += "[^/]";
|
|
66
|
+
i++;
|
|
67
|
+
} else {
|
|
68
|
+
regexStr += escapeRegex(glob[i]);
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return new RegExp(`^${regexStr}$`, caseInsensitive ? "i" : "");
|
|
74
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-escape scanning — detect subprocess calls in code
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const SHELL_ESCAPE_PATTERNS: Record<string, RegExp[]> = {
|
|
6
|
+
python: [
|
|
7
|
+
/\bos\.system\s*\(/i,
|
|
8
|
+
/\bsubprocess\.(?:call|run|Popen)\s*\(/i,
|
|
9
|
+
/\bexec\s*\(/i,
|
|
10
|
+
/\beval\s*\(/i,
|
|
11
|
+
],
|
|
12
|
+
javascript: [
|
|
13
|
+
/\bchild_process\b/i,
|
|
14
|
+
/\bexec\s*\(/i,
|
|
15
|
+
/\bexecSync\s*\(/i,
|
|
16
|
+
/\bspawn\s*\(/i,
|
|
17
|
+
/\beval\s*\(/i,
|
|
18
|
+
],
|
|
19
|
+
typescript: [
|
|
20
|
+
/\bchild_process\b/i,
|
|
21
|
+
/\bexec\s*\(/i,
|
|
22
|
+
/\bexecSync\s*\(/i,
|
|
23
|
+
/\bspawn\s*\(/i,
|
|
24
|
+
/\beval\s*\(/i,
|
|
25
|
+
],
|
|
26
|
+
ruby: [
|
|
27
|
+
/\bbacktick\b/i,
|
|
28
|
+
/\bsystem\s*\(/i,
|
|
29
|
+
/\bexec\s*\(/i,
|
|
30
|
+
/\bspawn\s*\(/i,
|
|
31
|
+
/\b`[^`]*`/,
|
|
32
|
+
],
|
|
33
|
+
go: [
|
|
34
|
+
/\bos\.Exec\s*\(/i,
|
|
35
|
+
/\bexec\.Command\s*\(/i,
|
|
36
|
+
],
|
|
37
|
+
php: [
|
|
38
|
+
/\bexec\s*\(/i,
|
|
39
|
+
/\bsystem\s*\(/i,
|
|
40
|
+
/\bshell_exec\s*\(/i,
|
|
41
|
+
/\bpassthru\s*\(/i,
|
|
42
|
+
/\bproc_open\s*\(/i,
|
|
43
|
+
],
|
|
44
|
+
rust: [
|
|
45
|
+
/\bCommand::new\s*\(/i,
|
|
46
|
+
/\bstd::process::Command\b/i,
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function scanForShellEscapes(code: string, language: string): string[] {
|
|
51
|
+
const patterns = SHELL_ESCAPE_PATTERNS[language] ?? [];
|
|
52
|
+
const findings: string[] = [];
|
|
53
|
+
|
|
54
|
+
for (const pattern of patterns) {
|
|
55
|
+
if (pattern.test(code)) {
|
|
56
|
+
findings.push(`Potential shell escape: ${pattern.source}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return findings;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function hasShellEscapes(code: string, language: string): boolean {
|
|
64
|
+
return scanForShellEscapes(code, language).length > 0;
|
|
65
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionDB — Persistent per-project SQLite database for session events
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { SessionEvent, StoredEvent, SessionMeta, ResumeRow } from "../types.js";
|
|
10
|
+
|
|
11
|
+
export function getWorktreeSuffix(): string {
|
|
12
|
+
const envSuffix = process.env.COMPACTOR_SESSION_SUFFIX;
|
|
13
|
+
if (envSuffix !== undefined) {
|
|
14
|
+
return envSuffix ? `__${envSuffix}` : "";
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const cwd = process.cwd();
|
|
18
|
+
const mainWorktree = execFileSync(
|
|
19
|
+
"git",
|
|
20
|
+
["worktree", "list", "--porcelain"],
|
|
21
|
+
{ encoding: "utf-8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] },
|
|
22
|
+
)
|
|
23
|
+
.split(/\r?\n/)
|
|
24
|
+
.find((l) => l.startsWith("worktree "))
|
|
25
|
+
?.replace("worktree ", "")
|
|
26
|
+
?.trim();
|
|
27
|
+
|
|
28
|
+
if (mainWorktree && cwd !== mainWorktree) {
|
|
29
|
+
const suffix = createHash("sha256").update(cwd).digest("hex").slice(0, 8);
|
|
30
|
+
return `__${suffix}`;
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// git not available
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function defaultDBPath(name: string): string {
|
|
39
|
+
return join(homedir(), ".unipi", "db", "compactor", `${name}.db`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Simple SQLite abstraction using dynamic imports
|
|
43
|
+
let sqliteLib: any = null;
|
|
44
|
+
|
|
45
|
+
async function getSQLite() {
|
|
46
|
+
if (sqliteLib) return sqliteLib;
|
|
47
|
+
try {
|
|
48
|
+
sqliteLib = await import("bun:sqlite" as any);
|
|
49
|
+
return sqliteLib;
|
|
50
|
+
} catch {
|
|
51
|
+
try {
|
|
52
|
+
sqliteLib = await import("node:sqlite" as any);
|
|
53
|
+
return sqliteLib;
|
|
54
|
+
} catch {
|
|
55
|
+
sqliteLib = await import("better-sqlite3");
|
|
56
|
+
return sqliteLib;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface PreparedStatement {
|
|
62
|
+
get(...args: any[]): any;
|
|
63
|
+
all(...args: any[]): any[];
|
|
64
|
+
run(...args: any[]): { changes: number };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const MAX_EVENTS_PER_SESSION = 1000;
|
|
68
|
+
const DEDUP_WINDOW = 5;
|
|
69
|
+
|
|
70
|
+
export class SessionDB {
|
|
71
|
+
private db: any;
|
|
72
|
+
private stmts: Map<string, PreparedStatement> = new Map();
|
|
73
|
+
private dbPath: string;
|
|
74
|
+
|
|
75
|
+
constructor(opts?: { dbPath?: string }) {
|
|
76
|
+
this.dbPath = opts?.dbPath ?? defaultDBPath("session");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async init(): Promise<void> {
|
|
80
|
+
const sqlite: any = await getSQLite();
|
|
81
|
+
// Handle different SQLite API shapes
|
|
82
|
+
const Database = sqlite.Database ?? sqlite.default?.Database ?? sqlite;
|
|
83
|
+
this.db = new Database(this.dbPath);
|
|
84
|
+
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
85
|
+
this.initSchema();
|
|
86
|
+
this.prepareStatements();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private initSchema(): void {
|
|
90
|
+
this.db.exec(`
|
|
91
|
+
CREATE TABLE IF NOT EXISTS session_events (
|
|
92
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
93
|
+
session_id TEXT NOT NULL,
|
|
94
|
+
type TEXT NOT NULL,
|
|
95
|
+
category TEXT NOT NULL,
|
|
96
|
+
priority INTEGER NOT NULL DEFAULT 2,
|
|
97
|
+
data TEXT NOT NULL,
|
|
98
|
+
project_dir TEXT NOT NULL DEFAULT '',
|
|
99
|
+
attribution_source TEXT NOT NULL DEFAULT 'unknown',
|
|
100
|
+
attribution_confidence REAL NOT NULL DEFAULT 0,
|
|
101
|
+
source_hook TEXT NOT NULL,
|
|
102
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
103
|
+
data_hash TEXT NOT NULL DEFAULT ''
|
|
104
|
+
);
|
|
105
|
+
CREATE INDEX IF NOT EXISTS idx_session_events_session ON session_events(session_id);
|
|
106
|
+
CREATE INDEX IF NOT EXISTS idx_session_events_type ON session_events(session_id, type);
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_session_events_priority ON session_events(session_id, priority);
|
|
108
|
+
|
|
109
|
+
CREATE TABLE IF NOT EXISTS session_meta (
|
|
110
|
+
session_id TEXT PRIMARY KEY,
|
|
111
|
+
project_dir TEXT NOT NULL,
|
|
112
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
113
|
+
last_event_at TEXT,
|
|
114
|
+
event_count INTEGER NOT NULL DEFAULT 0,
|
|
115
|
+
compact_count INTEGER NOT NULL DEFAULT 0
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
CREATE TABLE IF NOT EXISTS session_resume (
|
|
119
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
120
|
+
session_id TEXT NOT NULL UNIQUE,
|
|
121
|
+
snapshot TEXT NOT NULL,
|
|
122
|
+
event_count INTEGER NOT NULL,
|
|
123
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
124
|
+
consumed INTEGER NOT NULL DEFAULT 0
|
|
125
|
+
);
|
|
126
|
+
`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private prepareStatements(): void {
|
|
130
|
+
const p = (key: string, sql: string) => {
|
|
131
|
+
this.stmts.set(key, this.db.prepare(sql) as PreparedStatement);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
p("insertEvent", `INSERT INTO session_events (session_id, type, category, priority, data, project_dir, attribution_source, attribution_confidence, source_hook, data_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
135
|
+
p("getEvents", `SELECT id, session_id, type, category, priority, data, project_dir, attribution_source, attribution_confidence, source_hook, created_at, data_hash FROM session_events WHERE session_id = ? ORDER BY id ASC LIMIT ?`);
|
|
136
|
+
p("getEventCount", `SELECT COUNT(*) AS cnt FROM session_events WHERE session_id = ?`);
|
|
137
|
+
p("checkDuplicate", `SELECT 1 FROM (SELECT type, data_hash FROM session_events WHERE session_id = ? ORDER BY id DESC LIMIT ?) AS recent WHERE recent.type = ? AND recent.data_hash = ? LIMIT 1`);
|
|
138
|
+
p("evictLowestPriority", `DELETE FROM session_events WHERE id = (SELECT id FROM session_events WHERE session_id = ? ORDER BY priority ASC, id ASC LIMIT 1)`);
|
|
139
|
+
p("updateMetaLastEvent", `UPDATE session_meta SET last_event_at = datetime('now'), event_count = event_count + 1 WHERE session_id = ?`);
|
|
140
|
+
p("ensureSession", `INSERT OR IGNORE INTO session_meta (session_id, project_dir) VALUES (?, ?)`);
|
|
141
|
+
p("getSessionStats", `SELECT session_id, project_dir, started_at, last_event_at, event_count, compact_count FROM session_meta WHERE session_id = ?`);
|
|
142
|
+
p("incrementCompactCount", `UPDATE session_meta SET compact_count = compact_count + 1 WHERE session_id = ?`);
|
|
143
|
+
p("upsertResume", `INSERT INTO session_resume (session_id, snapshot, event_count) VALUES (?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET snapshot = excluded.snapshot, event_count = excluded.event_count, created_at = datetime('now'), consumed = 0`);
|
|
144
|
+
p("getResume", `SELECT snapshot, event_count, consumed FROM session_resume WHERE session_id = ?`);
|
|
145
|
+
p("markResumeConsumed", `UPDATE session_resume SET consumed = 1 WHERE session_id = ?`);
|
|
146
|
+
p("deleteEvents", `DELETE FROM session_events WHERE session_id = ?`);
|
|
147
|
+
p("deleteMeta", `DELETE FROM session_meta WHERE session_id = ?`);
|
|
148
|
+
p("deleteResume", `DELETE FROM session_resume WHERE session_id = ?`);
|
|
149
|
+
p("getOldSessions", `SELECT session_id FROM session_meta WHERE started_at < datetime('now', ? || ' days')`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private stmt(key: string): PreparedStatement {
|
|
153
|
+
return this.stmts.get(key)!;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
insertEvent(sessionId: string, event: SessionEvent, sourceHook: string = "PostToolUse"): void {
|
|
157
|
+
const dataHash = createHash("sha256").update(event.data).digest("hex").slice(0, 16).toUpperCase();
|
|
158
|
+
const projectDir = String(event.project_dir ?? "").trim();
|
|
159
|
+
const attributionSource = String(event.attribution_source ?? "unknown");
|
|
160
|
+
const rawConfidence = Number(event.attribution_confidence ?? 0);
|
|
161
|
+
const attributionConfidence = Number.isFinite(rawConfidence) ? Math.max(0, Math.min(1, rawConfidence)) : 0;
|
|
162
|
+
|
|
163
|
+
const transaction = this.db.transaction(() => {
|
|
164
|
+
const dup = this.stmt("checkDuplicate").get(sessionId, DEDUP_WINDOW, event.type, dataHash);
|
|
165
|
+
if (dup) return;
|
|
166
|
+
|
|
167
|
+
const countRow = this.stmt("getEventCount").get(sessionId) as { cnt: number };
|
|
168
|
+
if (countRow.cnt >= MAX_EVENTS_PER_SESSION) {
|
|
169
|
+
this.stmt("evictLowestPriority").run(sessionId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this.stmt("insertEvent").run(
|
|
173
|
+
sessionId, event.type, event.category, event.priority, event.data,
|
|
174
|
+
projectDir, attributionSource, attributionConfidence, sourceHook, dataHash,
|
|
175
|
+
);
|
|
176
|
+
this.stmt("updateMetaLastEvent").run(sessionId);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
transaction();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
getEvents(sessionId: string, opts?: { type?: string; minPriority?: number; limit?: number }): StoredEvent[] {
|
|
183
|
+
const limit = opts?.limit ?? 1000;
|
|
184
|
+
return this.stmt("getEvents").all(sessionId, limit) as StoredEvent[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
getEventCount(sessionId: string): number {
|
|
188
|
+
const row = this.stmt("getEventCount").get(sessionId) as { cnt: number };
|
|
189
|
+
return row.cnt;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
ensureSession(sessionId: string, projectDir: string): void {
|
|
193
|
+
this.stmt("ensureSession").run(sessionId, projectDir);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
getSessionStats(sessionId: string): SessionMeta | null {
|
|
197
|
+
const row = this.stmt("getSessionStats").get(sessionId) as SessionMeta | undefined;
|
|
198
|
+
return row ?? null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
incrementCompactCount(sessionId: string): void {
|
|
202
|
+
this.stmt("incrementCompactCount").run(sessionId);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
upsertResume(sessionId: string, snapshot: string, eventCount?: number): void {
|
|
206
|
+
this.stmt("upsertResume").run(sessionId, snapshot, eventCount ?? 0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
getResume(sessionId: string): ResumeRow | null {
|
|
210
|
+
const row = this.stmt("getResume").get(sessionId) as ResumeRow | undefined;
|
|
211
|
+
return row ?? null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
markResumeConsumed(sessionId: string): void {
|
|
215
|
+
this.stmt("markResumeConsumed").run(sessionId);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
deleteSession(sessionId: string): void {
|
|
219
|
+
this.db.transaction(() => {
|
|
220
|
+
this.stmt("deleteEvents").run(sessionId);
|
|
221
|
+
this.stmt("deleteResume").run(sessionId);
|
|
222
|
+
this.stmt("deleteMeta").run(sessionId);
|
|
223
|
+
})();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
cleanupOldSessions(maxAgeDays: number = 7): number {
|
|
227
|
+
const oldSessions = this.stmt("getOldSessions").all(`-${maxAgeDays}`) as Array<{ session_id: string }>;
|
|
228
|
+
for (const { session_id } of oldSessions) {
|
|
229
|
+
this.deleteSession(session_id);
|
|
230
|
+
}
|
|
231
|
+
return oldSessions.length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
close(): void {
|
|
235
|
+
try { this.db.close(); } catch { /* ignore */ }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event extraction from tool results and messages
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SessionEvent } from "../types.js";
|
|
6
|
+
|
|
7
|
+
const EventPriority = {
|
|
8
|
+
LOW: 1,
|
|
9
|
+
NORMAL: 2,
|
|
10
|
+
HIGH: 3,
|
|
11
|
+
CRITICAL: 4,
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export interface ToolResult {
|
|
15
|
+
toolName: string;
|
|
16
|
+
toolInput: Record<string, unknown>;
|
|
17
|
+
toolResponse?: string;
|
|
18
|
+
isError?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function extractEventsFromToolResult(result: ToolResult): SessionEvent[] {
|
|
22
|
+
const events: SessionEvent[] = [];
|
|
23
|
+
|
|
24
|
+
if (result.isError) {
|
|
25
|
+
events.push({
|
|
26
|
+
type: "tool_error",
|
|
27
|
+
category: "error",
|
|
28
|
+
data: `[${result.toolName}] ${String(result.toolResponse ?? "").slice(0, 500)}`,
|
|
29
|
+
priority: EventPriority.HIGH,
|
|
30
|
+
data_hash: "",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// File operations
|
|
35
|
+
if (["read", "Read", "edit", "Edit", "write", "Write"].includes(result.toolName)) {
|
|
36
|
+
const path = String(result.toolInput.file_path ?? result.toolInput.path ?? "");
|
|
37
|
+
if (path) {
|
|
38
|
+
const type = result.toolName.toLowerCase() === "write" ? "file_write"
|
|
39
|
+
: result.toolName.toLowerCase() === "edit" ? "file_edit"
|
|
40
|
+
: "file_read";
|
|
41
|
+
events.push({
|
|
42
|
+
type,
|
|
43
|
+
category: "file",
|
|
44
|
+
data: path,
|
|
45
|
+
priority: EventPriority.NORMAL,
|
|
46
|
+
data_hash: "",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Bash commands
|
|
52
|
+
if (["bash", "Bash"].includes(result.toolName)) {
|
|
53
|
+
const cmd = String(result.toolInput.command ?? result.toolInput.description ?? "").slice(0, 200);
|
|
54
|
+
if (cmd) {
|
|
55
|
+
events.push({
|
|
56
|
+
type: "bash_executed",
|
|
57
|
+
category: "env",
|
|
58
|
+
data: cmd,
|
|
59
|
+
priority: EventPriority.LOW,
|
|
60
|
+
data_hash: "",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Git operations
|
|
66
|
+
if (result.toolName.toLowerCase().includes("git")) {
|
|
67
|
+
events.push({
|
|
68
|
+
type: "git_operation",
|
|
69
|
+
category: "git",
|
|
70
|
+
data: `${result.toolName}: ${String(result.toolInput.command ?? "").slice(0, 200)}`,
|
|
71
|
+
priority: EventPriority.NORMAL,
|
|
72
|
+
data_hash: "",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return events;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function extractEventsFromUserMessage(content: string): SessionEvent[] {
|
|
80
|
+
const events: SessionEvent[] = [];
|
|
81
|
+
|
|
82
|
+
// Detect explicit preferences
|
|
83
|
+
const prefRe = /\b(prefer|preference|want|would like|should|must|need to|important|critical|avoid|don't|do not|never|always)\b/i;
|
|
84
|
+
if (prefRe.test(content)) {
|
|
85
|
+
events.push({
|
|
86
|
+
type: "user_preference",
|
|
87
|
+
category: "decision",
|
|
88
|
+
data: content.slice(0, 500),
|
|
89
|
+
priority: EventPriority.NORMAL,
|
|
90
|
+
data_hash: "",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Detect task mentions
|
|
95
|
+
const taskRe = /\b(task|todo|fixme|hack|bug|feature|story|epic)\b/i;
|
|
96
|
+
if (taskRe.test(content)) {
|
|
97
|
+
events.push({
|
|
98
|
+
type: "task_mentioned",
|
|
99
|
+
category: "task",
|
|
100
|
+
data: content.slice(0, 500),
|
|
101
|
+
priority: EventPriority.NORMAL,
|
|
102
|
+
data_hash: "",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return events;
|
|
107
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume injection — inject snapshot into session context post-compaction
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SessionDB } from "./db.js";
|
|
6
|
+
import { buildResumeSnapshot } from "./snapshot.js";
|
|
7
|
+
|
|
8
|
+
export async function injectResumeSnapshot(
|
|
9
|
+
db: SessionDB,
|
|
10
|
+
sessionId: string,
|
|
11
|
+
opts?: { searchTool?: string },
|
|
12
|
+
): Promise<string | null> {
|
|
13
|
+
const resume = db.getResume(sessionId);
|
|
14
|
+
if (!resume || resume.consumed) return null;
|
|
15
|
+
|
|
16
|
+
const events = db.getEvents(sessionId, { limit: 1000 });
|
|
17
|
+
const stats = db.getSessionStats(sessionId);
|
|
18
|
+
const snapshot = buildResumeSnapshot(events, {
|
|
19
|
+
compactCount: stats?.compact_count ?? 1,
|
|
20
|
+
searchTool: opts?.searchTool ?? "ctx_search",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
db.markResumeConsumed(sessionId);
|
|
24
|
+
return snapshot;
|
|
25
|
+
}
|