@lastboy/pai 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 Arik
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,189 @@
1
+ # PAI — Personal AI Supervisor
2
+
3
+ A local-first tool that observes how you work with AI coding agents (Claude Code
4
+ first) and turns that into knowledge you own.
5
+
6
+ PAI reads what already exists on your machine — coding-agent session
7
+ transcripts and guidance files — and never sends anything anywhere. It observes
8
+ and informs; it never controls or blocks the coding agent.
9
+
10
+ See [docs/principles.md](docs/principles.md) for the principles and validation
11
+ layers every feature is built against.
12
+
13
+ ## Install
14
+
15
+ Requires Node.js 22+ (developed on 24).
16
+
17
+ ```bash
18
+ npm install -g @lastboy/pai
19
+ ```
20
+
21
+ The package is scoped, but the command it installs is `pai`.
22
+
23
+ ### From source
24
+
25
+ ```bash
26
+ npm install
27
+ npm run build
28
+ npm link # makes `pai` available everywhere
29
+ ```
30
+
31
+ Without `npm link`, run any command through `npm run dev -- <command>`
32
+ (e.g. `npm run dev -- review`).
33
+
34
+ ## Commands
35
+
36
+ Run `pai --help`, or `pai <command> --help`, for the authoritative list.
37
+
38
+ ### `pai status`
39
+
40
+ Prints whether PAI is ready and which mode it runs in.
41
+
42
+ ### `pai review`
43
+
44
+ Reviews a Claude Code session for the **current folder**: title, model, start
45
+ and last-active dates, duration, conversation counts, activity counts, and
46
+ possible corrections with your own words as evidence.
47
+
48
+ Sessions are discovered automatically from `~/.claude/projects/<encoded-cwd>/`.
49
+ With several sessions for a folder, PAI lists them (short id, started, last
50
+ active, first prompt) and asks which one; pressing Enter picks the most recent.
51
+ Piped/non-interactive input picks the most recent without asking.
52
+
53
+ ```bash
54
+ pai review
55
+ ```
56
+
57
+ ### `pai rules`
58
+
59
+ Shows your guidelines from the CLAUDE.md files that apply to the current
60
+ folder — global (`~/.claude/CLAUDE.md`) and project (`CLAUDE.md`,
61
+ `CLAUDE.local.md`) — grouped by their markdown headings.
62
+
63
+ | Option | Effect |
64
+ |---|---|
65
+ | `--global` | only global rules |
66
+ | `--project` | only project rules |
67
+ | `--search <text>` | only rules whose text or category starts a word with `<text>` |
68
+
69
+ ```bash
70
+ pai rules
71
+ pai rules --project
72
+ pai rules --global --search decision
73
+ ```
74
+
75
+ Search matches word starts, so `--search ask` finds "Ask me before…" but not
76
+ "Small tasks".
77
+
78
+ ### `pai export`
79
+
80
+ Exports PAI's own rule store as portable, versioned JSON.
81
+
82
+ | Option | Effect |
83
+ |---|---|
84
+ | `--out <file>` | write to a file instead of stdout |
85
+ | `--scope global\|project` | export only that scope |
86
+
87
+ ```bash
88
+ pai export # everything, to stdout
89
+ pai export --scope global --out mine.json # only user-level rules
90
+ ```
91
+
92
+ ### `pai import <file>`
93
+
94
+ Merges a previously exported file into this machine. Rules carry their own
95
+ scope, so global rules land in the global store and project rules in the
96
+ current project's store — no flag needed.
97
+
98
+ The merge is **non-destructive**: new rules are added, rules you already have
99
+ gain any new evidence, and nothing is overwritten or removed. Re-importing the
100
+ same file changes nothing.
101
+
102
+ | Option | Effect |
103
+ |---|---|
104
+ | `--dry-run` | report what would change without writing |
105
+
106
+ ```bash
107
+ pai import mine.json --dry-run
108
+ pai import mine.json
109
+ ```
110
+
111
+ Invalid or unsupported files fail with a clear message and a non-zero exit code.
112
+
113
+ ### `pai experiment …`
114
+
115
+ Experimental commands. They may change or disappear, and they are the only
116
+ place PAI uses an LLM. They require [Ollama](https://ollama.com) running
117
+ locally; nothing else in PAI depends on it.
118
+
119
+ - `pai experiment correction [--model <name>] [--correction <n>]` — analyzes one
120
+ correction candidate from a real session: shows the deterministic detector
121
+ result, the exact context sent to the model, the structured result, and latency.
122
+ - `pai experiment distill [--model <name>] [--limit <n>]` — distills candidate
123
+ personal-guidance rules from recent user messages, so distillation quality can
124
+ be judged before rules are stored.
125
+
126
+ Default model: `qwen2.5:14b`.
127
+
128
+ ## Where PAI stores things
129
+
130
+ | Path | Contents |
131
+ |---|---|
132
+ | `~/.pai/rules.json` | your user-level rules |
133
+ | `<project>/.pai/rules.json` | rules scoped to that project |
134
+
135
+ The store is agent-neutral, versioned (`version: 1`), and portable: rule
136
+ identity comes from the rule text, so the same rule learned on two machines
137
+ merges cleanly instead of duplicating. Each rule keeps its category, scope,
138
+ source (`learned` or `manual`) and evidence — your actual words, with session id
139
+ and timestamp.
140
+
141
+ PAI **never writes** to `~/.claude/` or to any CLAUDE.md file.
142
+
143
+ ## What PAI reads
144
+
145
+ | Path | Used for |
146
+ |---|---|
147
+ | `~/.claude/projects/<encoded-cwd>/*.jsonl` | session transcripts (read-only) |
148
+ | `~/.claude/CLAUDE.md` | global guidelines |
149
+ | `<project>/CLAUDE.md`, `CLAUDE.local.md` | project guidelines |
150
+
151
+ The encoded folder name is the project path with every non-alphanumeric
152
+ character replaced by `-`.
153
+
154
+ ## Project layout
155
+
156
+ ```
157
+ src/core/ agent-independent logic (sessions, corrections, rules, review)
158
+ src/adapters/ per-agent integration — currently only claude/
159
+ src/persistence/ PAI's own store on disk
160
+ src/experiments/ local-LLM experiments (Ollama, optional)
161
+ src/cli/ command wiring and rendering
162
+ tests/ vitest suites and sanitized fixtures
163
+ docs/ principles and validation layers
164
+ ```
165
+
166
+ Supporting another agent (e.g. Codex CLI) means adding one adapter that answers
167
+ three questions: where its session logs live, how to parse them into PAI's
168
+ normalized events, and where its guidance file is. Nothing in `core/` changes.
169
+
170
+ ## Development
171
+
172
+ ```bash
173
+ npm run dev -- <command> # run from source via tsx
174
+ npm test # vitest
175
+ npm run typecheck # tsc, strict
176
+ npm run build # compile to dist/
177
+ ```
178
+
179
+ Tests use small sanitized fixtures. Real transcripts are never copied into the
180
+ repository.
181
+
182
+ ## Status
183
+
184
+ Working today: `status`, `review`, `rules`, `export`, `import`, and the two
185
+ experiments.
186
+
187
+ Next: `pai learn` — scan sessions, distill candidate rules, approve or reject
188
+ them interactively, and store the approved ones. Then auditing sessions against
189
+ stored rules (which rules the agent actually violated, with evidence).
@@ -0,0 +1,99 @@
1
+ import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, join } from 'node:path';
4
+ import { isRealUserPrompt } from './parser.js';
5
+ // Claude Code stores sessions in ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
6
+ // where the encoded name is the project cwd with every non-alphanumeric
7
+ // character replaced by '-'.
8
+ export function encodeProjectDir(cwd) {
9
+ return cwd.replace(/[^a-zA-Z0-9]/g, '-');
10
+ }
11
+ function listSessionFiles(cwd, claudeDir) {
12
+ const projectDir = join(claudeDir, 'projects', encodeProjectDir(cwd));
13
+ let names;
14
+ try {
15
+ names = readdirSync(projectDir);
16
+ }
17
+ catch {
18
+ return [];
19
+ }
20
+ const files = [];
21
+ for (const name of names) {
22
+ if (!name.endsWith('.jsonl'))
23
+ continue;
24
+ const path = join(projectDir, name);
25
+ try {
26
+ files.push({ path, mtime: statSync(path).mtime });
27
+ }
28
+ catch {
29
+ // File disappeared between readdir and stat — skip it.
30
+ }
31
+ }
32
+ return files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
33
+ }
34
+ // Reads only the head of the transcript — files can be many MB and the
35
+ // first prompt/timestamp appear early.
36
+ function readSessionPreview(path, maxBytes = 256 * 1024) {
37
+ let head;
38
+ try {
39
+ const fd = openSync(path, 'r');
40
+ try {
41
+ const buffer = Buffer.alloc(maxBytes);
42
+ const bytesRead = readSync(fd, buffer, 0, maxBytes, 0);
43
+ head = buffer.toString('utf8', 0, bytesRead);
44
+ }
45
+ finally {
46
+ closeSync(fd);
47
+ }
48
+ }
49
+ catch {
50
+ return {};
51
+ }
52
+ let startedAt;
53
+ let firstPrompt;
54
+ const lines = head.split('\n');
55
+ // Drop the last line — it may be cut mid-JSON by the byte limit.
56
+ for (const line of lines.slice(0, -1)) {
57
+ if (startedAt && firstPrompt)
58
+ break;
59
+ let entry;
60
+ try {
61
+ entry = JSON.parse(line);
62
+ }
63
+ catch {
64
+ continue;
65
+ }
66
+ if (entry === null || typeof entry !== 'object')
67
+ continue;
68
+ if (entry['type'] !== 'user' && entry['type'] !== 'assistant')
69
+ continue;
70
+ if (entry['isSidechain'] === true || entry['isMeta'] === true)
71
+ continue;
72
+ if (!startedAt && typeof entry['timestamp'] === 'string') {
73
+ const ts = new Date(entry['timestamp']);
74
+ if (!Number.isNaN(ts.getTime()))
75
+ startedAt = ts;
76
+ }
77
+ if (!firstPrompt && entry['type'] === 'user') {
78
+ const message = entry['message'];
79
+ const content = message?.['content'];
80
+ if (typeof content === 'string') {
81
+ const text = content.trim();
82
+ if (isRealUserPrompt(text))
83
+ firstPrompt = text;
84
+ }
85
+ }
86
+ }
87
+ return { startedAt, firstPrompt };
88
+ }
89
+ export function listSessions(cwd, claudeDir = join(homedir(), '.claude')) {
90
+ return listSessionFiles(cwd, claudeDir).map(({ path, mtime }) => ({
91
+ path,
92
+ id: basename(path, '.jsonl'),
93
+ lastActiveAt: mtime,
94
+ ...readSessionPreview(path),
95
+ }));
96
+ }
97
+ export function findLatestSessionFile(cwd, claudeDir = join(homedir(), '.claude')) {
98
+ return listSessionFiles(cwd, claudeDir)[0]?.path;
99
+ }
@@ -0,0 +1,71 @@
1
+ import { isRealUserPrompt } from './parser.js';
2
+ function toolTarget(name, input) {
3
+ const key = name === 'Bash' ? 'command' : name === 'NotebookEdit' ? 'notebook_path' : 'file_path';
4
+ return typeof input[key] === 'string' ? input[key] : undefined;
5
+ }
6
+ export function parseEvents(jsonl) {
7
+ const events = [];
8
+ const toolEventsByUseId = new Map();
9
+ for (const line of jsonl.split('\n')) {
10
+ if (!line.trim())
11
+ continue;
12
+ let entry;
13
+ try {
14
+ entry = JSON.parse(line);
15
+ }
16
+ catch {
17
+ continue;
18
+ }
19
+ if (entry === null || typeof entry !== 'object')
20
+ continue;
21
+ if (entry['type'] !== 'user' && entry['type'] !== 'assistant')
22
+ continue;
23
+ if (entry['isSidechain'] === true || entry['isMeta'] === true)
24
+ continue;
25
+ const message = entry['message'];
26
+ if (!message || typeof message !== 'object')
27
+ continue;
28
+ const timestamp = typeof entry['timestamp'] === 'string' ? entry['timestamp'] : undefined;
29
+ const content = message['content'];
30
+ if (entry['type'] === 'user') {
31
+ if (typeof content === 'string') {
32
+ const text = content.trim();
33
+ if (isRealUserPrompt(text))
34
+ events.push({ kind: 'user', timestamp, text });
35
+ }
36
+ else if (Array.isArray(content)) {
37
+ for (const block of content) {
38
+ if (block?.['type'] === 'tool_result' &&
39
+ block['is_error'] === true &&
40
+ typeof block['tool_use_id'] === 'string') {
41
+ const call = toolEventsByUseId.get(block['tool_use_id']);
42
+ if (call)
43
+ call.failed = true;
44
+ }
45
+ }
46
+ }
47
+ }
48
+ else if (Array.isArray(content)) {
49
+ for (const block of content) {
50
+ if (block?.['type'] === 'text' && typeof block['text'] === 'string') {
51
+ const text = block['text'].trim();
52
+ if (text)
53
+ events.push({ kind: 'assistant', timestamp, text });
54
+ }
55
+ else if (block?.['type'] === 'tool_use' && typeof block['name'] === 'string') {
56
+ const event = {
57
+ kind: 'tool',
58
+ timestamp,
59
+ name: block['name'],
60
+ target: toolTarget(block['name'], (block['input'] ?? {})),
61
+ failed: false,
62
+ };
63
+ events.push(event);
64
+ if (typeof block['id'] === 'string')
65
+ toolEventsByUseId.set(block['id'], event);
66
+ }
67
+ }
68
+ }
69
+ }
70
+ return events;
71
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ // Claude Code guidance locations: ~/.claude/CLAUDE.md (global) and
5
+ // CLAUDE.md / CLAUDE.local.md at the project root. Parent-directory
6
+ // traversal is not implemented yet.
7
+ export function findGuidelineFiles(cwd, home = homedir()) {
8
+ const candidates = [
9
+ { path: join(home, '.claude', 'CLAUDE.md'), scope: 'global' },
10
+ { path: join(cwd, 'CLAUDE.md'), scope: 'project' },
11
+ { path: join(cwd, 'CLAUDE.local.md'), scope: 'project' },
12
+ ];
13
+ return candidates.filter((candidate) => existsSync(candidate.path));
14
+ }
15
+ const HEADING = /^#{1,6}\s+(.+?)\s*$/;
16
+ const BULLET = /^\s*[-*]\s+(.+?)\s*$/;
17
+ export function parseGuidelines(markdown) {
18
+ const guidelines = [];
19
+ let category = 'General';
20
+ for (const line of markdown.split('\n')) {
21
+ const heading = HEADING.exec(line);
22
+ if (heading?.[1]) {
23
+ category = heading[1];
24
+ continue;
25
+ }
26
+ const bullet = BULLET.exec(line);
27
+ if (bullet?.[1]) {
28
+ guidelines.push({ category, text: bullet[1] });
29
+ }
30
+ }
31
+ return guidelines;
32
+ }
@@ -0,0 +1,117 @@
1
+ // Maps Claude Code tool names to normalized kinds. Anything unknown is 'other'.
2
+ function normalizeToolCall(name, input) {
3
+ const inputObj = (input ?? {});
4
+ const str = (key) => typeof inputObj[key] === 'string' ? inputObj[key] : undefined;
5
+ switch (name) {
6
+ case 'Read':
7
+ return { name, kind: 'file-read', target: str('file_path'), failed: false };
8
+ case 'Edit':
9
+ case 'Write':
10
+ return { name, kind: 'file-write', target: str('file_path'), failed: false };
11
+ case 'NotebookEdit':
12
+ return { name, kind: 'file-write', target: str('notebook_path'), failed: false };
13
+ case 'Bash':
14
+ return { name, kind: 'command', target: str('command'), failed: false };
15
+ default:
16
+ return { name, kind: 'other', failed: false };
17
+ }
18
+ }
19
+ // Real user prompts are string content; strings starting with '<' or '['
20
+ // are UI artifacts (slash-command XML, "[Request interrupted...]").
21
+ export function isRealUserPrompt(text) {
22
+ return text.length > 0 && !text.startsWith('<') && !text.startsWith('[');
23
+ }
24
+ export function parseTranscript(jsonl, sessionId) {
25
+ const userMessages = [];
26
+ const toolCalls = [];
27
+ const callsByToolUseId = new Map();
28
+ const assistantMessageIds = new Set();
29
+ let title;
30
+ let model;
31
+ let startedAt;
32
+ let endedAt;
33
+ for (const line of jsonl.split('\n')) {
34
+ if (!line.trim())
35
+ continue;
36
+ let entry;
37
+ try {
38
+ entry = JSON.parse(line);
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ if (entry === null || typeof entry !== 'object')
44
+ continue;
45
+ if (entry['type'] === 'ai-title') {
46
+ // Title lines repeat as the session evolves — the last one wins.
47
+ if (typeof entry['aiTitle'] === 'string')
48
+ title = entry['aiTitle'];
49
+ continue;
50
+ }
51
+ if (entry['type'] !== 'user' && entry['type'] !== 'assistant')
52
+ continue;
53
+ if (entry['isSidechain'] === true || entry['isMeta'] === true)
54
+ continue;
55
+ const message = entry['message'];
56
+ if (!message || typeof message !== 'object')
57
+ continue;
58
+ if (typeof entry['timestamp'] === 'string') {
59
+ const ts = new Date(entry['timestamp']);
60
+ if (!Number.isNaN(ts.getTime())) {
61
+ if (!startedAt || ts < startedAt)
62
+ startedAt = ts;
63
+ if (!endedAt || ts > endedAt)
64
+ endedAt = ts;
65
+ }
66
+ }
67
+ const content = message['content'];
68
+ if (entry['type'] === 'user') {
69
+ if (typeof content === 'string') {
70
+ const text = content.trim();
71
+ if (isRealUserPrompt(text)) {
72
+ userMessages.push({
73
+ timestamp: typeof entry['timestamp'] === 'string' ? entry['timestamp'] : undefined,
74
+ text,
75
+ });
76
+ }
77
+ }
78
+ else if (Array.isArray(content)) {
79
+ for (const block of content) {
80
+ if (block?.['type'] === 'tool_result' &&
81
+ block['is_error'] === true &&
82
+ typeof block['tool_use_id'] === 'string') {
83
+ const call = callsByToolUseId.get(block['tool_use_id']);
84
+ if (call)
85
+ call.failed = true;
86
+ }
87
+ }
88
+ }
89
+ }
90
+ else {
91
+ if (typeof message['model'] === 'string')
92
+ model = message['model'];
93
+ if (typeof message['id'] === 'string')
94
+ assistantMessageIds.add(message['id']);
95
+ if (Array.isArray(content)) {
96
+ for (const block of content) {
97
+ if (block?.['type'] === 'tool_use' && typeof block['name'] === 'string') {
98
+ const call = normalizeToolCall(block['name'], block['input']);
99
+ toolCalls.push(call);
100
+ if (typeof block['id'] === 'string')
101
+ callsByToolUseId.set(block['id'], call);
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+ return {
108
+ id: sessionId,
109
+ title,
110
+ model,
111
+ startedAt,
112
+ endedAt,
113
+ userMessages,
114
+ assistantMessageCount: assistantMessageIds.size,
115
+ toolCalls,
116
+ };
117
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { createProgram } from './program.js';
3
+ await createProgram((line) => console.log(line)).parseAsync(process.argv);
@@ -0,0 +1,303 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { Command } from 'commander';
6
+ import { listSessions } from '../adapters/claude/discovery.js';
7
+ import { parseTranscript } from '../adapters/claude/parser.js';
8
+ import { buildReview } from '../core/review.js';
9
+ import { getStatus, renderStatus } from '../core/status.js';
10
+ import { parseEvents } from '../adapters/claude/events.js';
11
+ import { detectPossibleCorrections } from '../core/corrections.js';
12
+ import { analyzeWithOllama, buildCorrectionContext, buildPrompt, } from '../experiments/correction-analysis.js';
13
+ import { ollamaGenerate } from '../experiments/ollama.js';
14
+ import { buildDistillPrompt, parseDistillResponse } from '../experiments/rule-distillation.js';
15
+ import { findGuidelineFiles, parseGuidelines } from '../adapters/claude/guidelines.js';
16
+ import { filterGuidelineGroups } from '../core/guidelines.js';
17
+ import { emptyStore, mergeStores, parseRuleStore, serializeRuleStore, } from '../core/rule-store.js';
18
+ import { globalStorePath, projectStorePath, readStore, writeStore, } from '../persistence/rule-store-files.js';
19
+ import { parseSelection, renderGuidelines, renderReview, renderSessionList } from './render.js';
20
+ async function ask(question) {
21
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
22
+ const answer = await rl.question(question);
23
+ rl.close();
24
+ return answer;
25
+ }
26
+ async function chooseSession(out) {
27
+ const sessions = listSessions(process.cwd());
28
+ if (sessions.length === 0) {
29
+ out('No Claude Code sessions found for this project.');
30
+ return undefined;
31
+ }
32
+ if (sessions.length === 1)
33
+ return sessions[0];
34
+ for (const line of renderSessionList(sessions))
35
+ out(line);
36
+ out('');
37
+ if (!process.stdin.isTTY) {
38
+ out('(non-interactive input: using the most recent session)');
39
+ out('');
40
+ return sessions[0];
41
+ }
42
+ const answer = await ask(`Select session [1-${sessions.length}, Enter = 1]: `);
43
+ const index = parseSelection(answer, sessions.length);
44
+ if (index === undefined) {
45
+ out('Invalid selection.');
46
+ return undefined;
47
+ }
48
+ out('');
49
+ return sessions[index];
50
+ }
51
+ // Same relative depth from src/cli and dist/cli.
52
+ function packageVersion() {
53
+ const pkg = JSON.parse(readFileSync(join(import.meta.dirname, '..', '..', 'package.json'), 'utf8'));
54
+ return pkg.version ?? '0.0.0';
55
+ }
56
+ export function createProgram(out) {
57
+ const program = new Command('pai');
58
+ program.description('PAI — Personal AI Supervisor');
59
+ program.version(packageVersion(), '-v, --version', 'show the PAI version');
60
+ program
61
+ .command('status')
62
+ .description('Show PAI status')
63
+ .action(() => {
64
+ for (const line of renderStatus(getStatus())) {
65
+ out(line);
66
+ }
67
+ });
68
+ program
69
+ .command('review')
70
+ .description('Review the latest Claude Code session for this project')
71
+ .action(async () => {
72
+ const chosen = await chooseSession(out);
73
+ if (!chosen) {
74
+ process.exitCode = 1;
75
+ return;
76
+ }
77
+ const transcript = await readFile(chosen.path, 'utf8');
78
+ const session = parseTranscript(transcript, chosen.id);
79
+ for (const line of renderReview(buildReview(session))) {
80
+ out(line);
81
+ }
82
+ });
83
+ program
84
+ .command('rules')
85
+ .description('Show your guidelines from CLAUDE.md files, categorized')
86
+ .option('--global', 'only global rules (~/.claude/CLAUDE.md)')
87
+ .option('--project', 'only project rules (CLAUDE.md, CLAUDE.local.md)')
88
+ .option('--search <text>', 'only rules whose text or category starts a word with <text>')
89
+ .action(async (options) => {
90
+ const files = findGuidelineFiles(process.cwd());
91
+ const groups = await Promise.all(files.map(async (file) => ({
92
+ scope: file.scope,
93
+ path: file.path,
94
+ guidelines: parseGuidelines(await readFile(file.path, 'utf8')),
95
+ })));
96
+ // --global and --project together = no scope filter (same as neither).
97
+ const scope = options.global && !options.project
98
+ ? 'global'
99
+ : options.project && !options.global
100
+ ? 'project'
101
+ : undefined;
102
+ const filtered = filterGuidelineGroups(groups, { scope, search: options.search });
103
+ if (filtered.length === 0 && groups.length > 0) {
104
+ out(`No rules matching the given filters.`);
105
+ return;
106
+ }
107
+ for (const line of renderGuidelines(filtered))
108
+ out(line);
109
+ });
110
+ program
111
+ .command('export')
112
+ .description('Export PAI rules to a portable JSON file (or stdout)')
113
+ .option('--out <file>', 'write to this file instead of stdout')
114
+ .option('--scope <scope>', 'export only "global" or "project" rules')
115
+ .action((options) => {
116
+ if (options.scope && options.scope !== 'global' && options.scope !== 'project') {
117
+ out('--scope must be "global" or "project".');
118
+ process.exitCode = 1;
119
+ return;
120
+ }
121
+ const global = readStore(globalStorePath());
122
+ const project = readStore(projectStorePath(process.cwd()));
123
+ const combined = {
124
+ ...emptyStore(),
125
+ rules: [...global.rules, ...project.rules].filter((rule) => options.scope === undefined || rule.scope === options.scope),
126
+ };
127
+ const json = serializeRuleStore(combined);
128
+ if (options.out === undefined) {
129
+ out(json.trimEnd());
130
+ return;
131
+ }
132
+ writeFileSync(options.out, json, 'utf8');
133
+ out(`Exported ${combined.rules.length} rule(s) to ${options.out}`);
134
+ });
135
+ program
136
+ .command('import')
137
+ .argument('<file>', 'JSON file previously produced by "pai export"')
138
+ .description('Merge rules from a file into this machine (never overwrites)')
139
+ .option('--dry-run', 'show what would change without writing')
140
+ .action(async (file, options) => {
141
+ let incoming;
142
+ try {
143
+ incoming = parseRuleStore(await readFile(file, 'utf8'));
144
+ }
145
+ catch (error) {
146
+ out(error instanceof Error ? error.message : String(error));
147
+ process.exitCode = 1;
148
+ return;
149
+ }
150
+ const targets = [
151
+ { scope: 'global', path: globalStorePath() },
152
+ { scope: 'project', path: projectStorePath(process.cwd()) },
153
+ ];
154
+ for (const target of targets) {
155
+ const rules = incoming.rules.filter((rule) => rule.scope === target.scope);
156
+ if (rules.length === 0)
157
+ continue;
158
+ const result = mergeStores(readStore(target.path), { ...emptyStore(), rules });
159
+ if (!options.dryRun)
160
+ writeStore(target.path, result.store);
161
+ out(`${target.scope}: ${result.added} added, ${result.merged} updated with new evidence, ${result.store.rules.length} total → ${target.path}`);
162
+ }
163
+ if (incoming.rules.length === 0)
164
+ out('Nothing to import — the file contains no rules.');
165
+ if (options.dryRun)
166
+ out('(dry run — nothing was written)');
167
+ });
168
+ const experiment = program
169
+ .command('experiment')
170
+ .description('Experimental features — may change or disappear');
171
+ experiment
172
+ .command('correction')
173
+ .description('EXPERIMENT: analyze one correction candidate with a local Ollama model')
174
+ .option('--model <name>', 'Ollama model to use', 'qwen2.5:14b')
175
+ .option('--correction <n>', 'correction candidate number (1-based, default: last)')
176
+ .action(async (options) => {
177
+ const chosen = await chooseSession(out);
178
+ if (!chosen) {
179
+ process.exitCode = 1;
180
+ return;
181
+ }
182
+ const transcript = await readFile(chosen.path, 'utf8');
183
+ const events = parseEvents(transcript);
184
+ const candidates = events
185
+ .map((event, index) => ({ event, index }))
186
+ .filter(({ event }) => event.kind === 'user' &&
187
+ detectPossibleCorrections([{ text: event.text }]).length > 0);
188
+ if (candidates.length === 0) {
189
+ out('No correction candidates found in this session.');
190
+ process.exitCode = 1;
191
+ return;
192
+ }
193
+ out(`Correction candidates in session ${chosen.id.slice(0, 8)}:`);
194
+ candidates.forEach(({ event }, i) => {
195
+ const text = event.kind === 'user' ? event.text : '';
196
+ out(` ${i + 1}. "${text.replace(/\s+/g, ' ').slice(0, 70)}"`);
197
+ });
198
+ out('');
199
+ let pick = candidates.length - 1;
200
+ if (options.correction !== undefined) {
201
+ const index = parseSelection(options.correction, candidates.length);
202
+ if (index === undefined) {
203
+ out('Invalid --correction number.');
204
+ process.exitCode = 1;
205
+ return;
206
+ }
207
+ pick = index;
208
+ }
209
+ else if (process.stdin.isTTY) {
210
+ const answer = await ask(`Select candidate [1-${candidates.length}, Enter = ${candidates.length}]: `);
211
+ const index = answer.trim() === '' ? candidates.length - 1 : parseSelection(answer, candidates.length);
212
+ if (index === undefined) {
213
+ out('Invalid selection.');
214
+ process.exitCode = 1;
215
+ return;
216
+ }
217
+ pick = index;
218
+ }
219
+ const candidate = candidates[pick];
220
+ if (!candidate)
221
+ return;
222
+ const context = buildCorrectionContext(events, candidate.index);
223
+ const prompt = buildPrompt(context);
224
+ out('=== 1. Deterministic detector ===');
225
+ out(`Flagged as possible correction (keyword match).`);
226
+ out('');
227
+ out('=== 2. Context sent to Ollama ===');
228
+ out(prompt);
229
+ out('');
230
+ out(`Context size: ${prompt.length} chars (~${Math.round(prompt.length / 4)} tokens)`);
231
+ out('');
232
+ out(`=== 3. Ollama result (${options.model}) ===`);
233
+ try {
234
+ const result = await analyzeWithOllama(prompt, options.model);
235
+ out(`Raw response: ${result.raw.trim()}`);
236
+ out('');
237
+ if (result.parsed) {
238
+ out(`Parsed: isCorrection=${result.parsed.isCorrection} category=${result.parsed.category} confidence=${result.parsed.confidence}`);
239
+ out(`Reason: ${result.parsed.reason}`);
240
+ }
241
+ else {
242
+ out('Could not parse a structured result from the response.');
243
+ }
244
+ out('');
245
+ out('=== 4. Latency ===');
246
+ out(`Wall clock: ${(result.latencyMs / 1000).toFixed(1)}s`);
247
+ if (result.modelDurationMs !== undefined) {
248
+ out(`Ollama total_duration: ${(result.modelDurationMs / 1000).toFixed(1)}s`);
249
+ }
250
+ }
251
+ catch (error) {
252
+ out(`Ollama call failed: ${error instanceof Error ? error.message : String(error)}`);
253
+ out('Is Ollama running? (ollama serve, then ollama pull <model>)');
254
+ process.exitCode = 1;
255
+ }
256
+ });
257
+ experiment
258
+ .command('distill')
259
+ .description('EXPERIMENT: distill candidate rules from recent user messages via Ollama')
260
+ .option('--model <name>', 'Ollama model to use', 'qwen2.5:14b')
261
+ .option('--limit <n>', 'how many recent user messages to analyze', '10')
262
+ .action(async (options) => {
263
+ const chosen = await chooseSession(out);
264
+ if (!chosen) {
265
+ process.exitCode = 1;
266
+ return;
267
+ }
268
+ const transcript = await readFile(chosen.path, 'utf8');
269
+ const messages = parseEvents(transcript).filter((e) => e.kind === 'user');
270
+ const limit = Math.max(1, Number(options.limit) || 10);
271
+ const sample = messages.slice(-limit);
272
+ out(`Distilling from the last ${sample.length} user messages (session ${chosen.id.slice(0, 8)}, model ${options.model})`);
273
+ out('');
274
+ let totalMs = 0;
275
+ let ruleCount = 0;
276
+ for (const [i, message] of sample.entries()) {
277
+ const text = message.kind === 'user' ? message.text : '';
278
+ out(`--- Message ${i + 1}/${sample.length} ---`);
279
+ out(` "${text.replace(/\s+/g, ' ').slice(0, 120)}${text.length > 120 ? '...' : ''}"`);
280
+ try {
281
+ const generation = await ollamaGenerate(buildDistillPrompt(text), options.model);
282
+ totalMs += generation.latencyMs;
283
+ const rules = parseDistillResponse(generation.raw);
284
+ if (rules.length === 0) {
285
+ out(' → no lasting guidance found');
286
+ }
287
+ for (const rule of rules) {
288
+ ruleCount += 1;
289
+ out(` → [${rule.category}] ${rule.rule} (confidence ${rule.confidence})`);
290
+ }
291
+ }
292
+ catch (error) {
293
+ out(` Ollama call failed: ${error instanceof Error ? error.message : String(error)}`);
294
+ process.exitCode = 1;
295
+ return;
296
+ }
297
+ }
298
+ out('');
299
+ out(`Layers: L0 ✓ 1 session · L1 ✓ ${sample.length} messages · L2 ✓ ${options.model}`);
300
+ out(`Candidates: ${ruleCount} rules · total LLM time ${(totalMs / 1000).toFixed(1)}s`);
301
+ });
302
+ return program;
303
+ }
@@ -0,0 +1,113 @@
1
+ export function renderGuidelines(groups) {
2
+ const lines = ['PAI — Guidelines'];
3
+ for (const group of groups) {
4
+ lines.push('');
5
+ lines.push(`${group.scope === 'global' ? 'Global' : 'Project'} (${group.path})`);
6
+ if (group.guidelines.length === 0) {
7
+ lines.push(' (no guidelines found)');
8
+ continue;
9
+ }
10
+ let category;
11
+ for (const guideline of group.guidelines) {
12
+ if (guideline.category !== category) {
13
+ category = guideline.category;
14
+ lines.push('');
15
+ lines.push(` ${category}`);
16
+ }
17
+ lines.push(` • ${guideline.text}`);
18
+ }
19
+ }
20
+ if (groups.length === 0) {
21
+ lines.push('');
22
+ lines.push('No CLAUDE.md files found (global or project).');
23
+ }
24
+ return lines;
25
+ }
26
+ function formatDuration(ms) {
27
+ const totalMinutes = Math.floor(ms / 60_000);
28
+ const hours = Math.floor(totalMinutes / 60);
29
+ const minutes = totalMinutes % 60;
30
+ if (hours > 0)
31
+ return `${hours}h ${minutes}m`;
32
+ if (totalMinutes > 0)
33
+ return `${minutes}m`;
34
+ return `${Math.floor(ms / 1000)}s`;
35
+ }
36
+ function formatClock(date) {
37
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
38
+ }
39
+ function stat(label, value) {
40
+ return ` ${label.padEnd(16)}${String(value).padStart(5)}`;
41
+ }
42
+ function quote(text) {
43
+ const singleLine = text.replace(/\s+/g, ' ').trim();
44
+ return singleLine.length > 60 ? `"${singleLine.slice(0, 57)}..."` : `"${singleLine}"`;
45
+ }
46
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
47
+ function formatDay(date) {
48
+ return `${date.getDate()} ${MONTHS[date.getMonth()]} ${formatClock(date)}`;
49
+ }
50
+ function formatFullDate(date) {
51
+ return `${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()}, ${formatClock(date)}`;
52
+ }
53
+ function snippet(text, max = 40) {
54
+ const singleLine = text.replace(/\s+/g, ' ').trim();
55
+ return singleLine.length > max ? `${singleLine.slice(0, max - 3)}...` : singleLine;
56
+ }
57
+ export function renderSessionList(sessions) {
58
+ const lines = ['Sessions for this project:'];
59
+ sessions.forEach((session, index) => {
60
+ const started = session.startedAt ? `started ${formatDay(session.startedAt)}` : 'started ?';
61
+ const lastActive = `last active ${formatDay(session.lastActiveAt)}`;
62
+ const prompt = session.firstPrompt ? ` "${snippet(session.firstPrompt)}"` : '';
63
+ lines.push(` ${String(index + 1).padStart(2)}. ${session.id.slice(0, 8)} ${started} ${lastActive}${prompt}`);
64
+ });
65
+ return lines;
66
+ }
67
+ // Empty input selects the first (most recent) session; otherwise a 1-based index.
68
+ export function parseSelection(input, count) {
69
+ const trimmed = input.trim();
70
+ if (trimmed === '')
71
+ return 0;
72
+ if (!/^\d+$/.test(trimmed))
73
+ return undefined;
74
+ const n = Number(trimmed);
75
+ return n >= 1 && n <= count ? n - 1 : undefined;
76
+ }
77
+ export function renderReview(review) {
78
+ const lines = [];
79
+ lines.push('PAI — Session Review');
80
+ lines.push(`Session: ${review.sessionId}`);
81
+ if (review.title)
82
+ lines.push(`Title: ${review.title}`);
83
+ lines.push(`Model: ${review.model ?? 'unknown'}`);
84
+ if (review.startedAt)
85
+ lines.push(`Started: ${formatFullDate(review.startedAt)}`);
86
+ if (review.endedAt)
87
+ lines.push(`Last active: ${formatFullDate(review.endedAt)}`);
88
+ if (review.durationMs !== undefined)
89
+ lines.push(`Duration: ${formatDuration(review.durationMs)}`);
90
+ lines.push('');
91
+ lines.push('Conversation');
92
+ lines.push(stat('User messages', review.userMessageCount));
93
+ lines.push(stat('Claude messages', review.assistantMessageCount));
94
+ lines.push(stat('Tool calls', review.toolCallCount));
95
+ lines.push(stat('Tool failures', review.toolFailureCount));
96
+ lines.push('');
97
+ lines.push('Activity');
98
+ lines.push(stat('Files read', review.filesRead));
99
+ lines.push(stat('Files modified', review.filesModified));
100
+ lines.push(stat('Commands', review.commandCount));
101
+ lines.push('');
102
+ lines.push(`Possible corrections ${review.possibleCorrections.length}`);
103
+ const recent = review.possibleCorrections.slice(-5);
104
+ if (recent.length > 0) {
105
+ lines.push('');
106
+ lines.push('Recent user corrections:');
107
+ for (const correction of recent) {
108
+ const time = correction.timestamp ? `${formatClock(new Date(correction.timestamp))} ` : '';
109
+ lines.push(` ${time}${quote(correction.text)}`);
110
+ }
111
+ }
112
+ return lines;
113
+ }
@@ -0,0 +1,20 @@
1
+ // Deliberately simple first pass. These phrases MAY indicate the user is
2
+ // correcting or redirecting the agent — they are not classified as mistakes.
3
+ // "no" and "stop" are anchored to the message start to reduce noise.
4
+ const CORRECTION_PATTERNS = [
5
+ /^no\b/i,
6
+ /^stop\b/i,
7
+ /\bdon'?t\b/i,
8
+ /\bthat'?s not what i asked\b/i,
9
+ /\bwe already\b/i,
10
+ /\bwe agreed\b/i,
11
+ /\bwhy did you\b/i,
12
+ /\byou forgot\b/i,
13
+ /\bread\b.{0,60}\bfirst\b/i,
14
+ /\bthat'?s wrong\b/i,
15
+ ];
16
+ export function detectPossibleCorrections(messages) {
17
+ return messages
18
+ .filter((m) => CORRECTION_PATTERNS.some((p) => p.test(m.text)))
19
+ .map((m) => ({ timestamp: m.timestamp, text: m.text }));
20
+ }
@@ -0,0 +1,16 @@
1
+ export function filterGuidelineGroups(groups, filters) {
2
+ const query = filters.search?.trim();
3
+ // Word-start match: "ask" hits "Ask"/"asking" but not "tasks".
4
+ const matcher = query === undefined || query === ''
5
+ ? undefined
6
+ : new RegExp(`\\b${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i');
7
+ return groups
8
+ .filter((group) => filters.scope === undefined || group.scope === filters.scope)
9
+ .map((group) => matcher === undefined
10
+ ? group
11
+ : {
12
+ ...group,
13
+ guidelines: group.guidelines.filter((g) => matcher.test(g.text) || matcher.test(g.category)),
14
+ })
15
+ .filter((group) => group.guidelines.length > 0 || matcher === undefined);
16
+ }
@@ -0,0 +1,27 @@
1
+ import { detectPossibleCorrections } from './corrections.js';
2
+ function distinctTargets(session, kind) {
3
+ const targets = session.toolCalls
4
+ .filter((c) => c.kind === kind && c.target !== undefined)
5
+ .map((c) => c.target);
6
+ return new Set(targets).size;
7
+ }
8
+ export function buildReview(session) {
9
+ return {
10
+ sessionId: session.id,
11
+ title: session.title,
12
+ model: session.model,
13
+ startedAt: session.startedAt,
14
+ endedAt: session.endedAt,
15
+ durationMs: session.startedAt && session.endedAt
16
+ ? session.endedAt.getTime() - session.startedAt.getTime()
17
+ : undefined,
18
+ userMessageCount: session.userMessages.length,
19
+ assistantMessageCount: session.assistantMessageCount,
20
+ toolCallCount: session.toolCalls.length,
21
+ toolFailureCount: session.toolCalls.filter((c) => c.failed).length,
22
+ filesRead: distinctTargets(session, 'file-read'),
23
+ filesModified: distinctTargets(session, 'file-write'),
24
+ commandCount: session.toolCalls.filter((c) => c.kind === 'command').length,
25
+ possibleCorrections: detectPossibleCorrections(session.userMessages),
26
+ };
27
+ }
Binary file
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ export function getStatus() {
2
+ return { status: 'ready', mode: 'local' };
3
+ }
4
+ export function renderStatus(status) {
5
+ return ['PAI', `Status: ${status.status}`, `Mode: ${status.mode}`];
6
+ }
@@ -0,0 +1,84 @@
1
+ import { ollamaGenerate } from './ollama.js';
2
+ const CATEGORIES = [
3
+ 'scope_drift',
4
+ 'repeated_failure',
5
+ 'ignored_guidance',
6
+ 'misunderstanding',
7
+ 'regression',
8
+ 'other',
9
+ 'not_correction',
10
+ ];
11
+ const MAX_PRECEDING_EVENTS = 5;
12
+ const MAX_EVENT_CHARS = 300;
13
+ const MAX_GOAL_CHARS = 500;
14
+ function clip(text, max) {
15
+ const singleLine = text.replace(/\s+/g, ' ').trim();
16
+ return singleLine.length > max ? `${singleLine.slice(0, max - 3)}...` : singleLine;
17
+ }
18
+ export function buildCorrectionContext(events, correctionIndex) {
19
+ const correction = events[correctionIndex];
20
+ if (!correction || correction.kind !== 'user') {
21
+ throw new Error('correctionIndex must point at a user event');
22
+ }
23
+ const firstUser = events.find((e, i) => e.kind === 'user' && i < correctionIndex);
24
+ return {
25
+ goal: firstUser?.kind === 'user' ? firstUser.text : undefined,
26
+ // The goal message is sent separately — don't duplicate it in the window.
27
+ events: events
28
+ .slice(Math.max(0, correctionIndex - MAX_PRECEDING_EVENTS), correctionIndex)
29
+ .filter((e) => e !== firstUser),
30
+ correction: correction.text,
31
+ };
32
+ }
33
+ function describeEvent(event) {
34
+ switch (event.kind) {
35
+ case 'user':
36
+ return `[user] ${clip(event.text, MAX_EVENT_CHARS)}`;
37
+ case 'assistant':
38
+ return `[assistant] ${clip(event.text, MAX_EVENT_CHARS)}`;
39
+ case 'tool':
40
+ return `[tool] ${event.name}${event.target ? ` ${clip(event.target, 120)}` : ''}${event.failed ? ' (FAILED)' : ''}`;
41
+ }
42
+ }
43
+ export function buildPrompt(context) {
44
+ const parts = [
45
+ 'You are analyzing one moment in a coding-agent session.',
46
+ 'Decide whether the user message below is the user CORRECTING or REDIRECTING the AI assistant (as opposed to a normal new request, answer, or approval).',
47
+ '',
48
+ ];
49
+ if (context.goal) {
50
+ parts.push('User request earlier in the session:', `"${clip(context.goal, MAX_GOAL_CHARS)}"`, '');
51
+ }
52
+ if (context.events.length > 0) {
53
+ parts.push('Events immediately before the message:');
54
+ context.events.forEach((event, i) => parts.push(`${i + 1}. ${describeEvent(event)}`));
55
+ parts.push('');
56
+ }
57
+ parts.push('User message to analyze:', `"${clip(context.correction, MAX_GOAL_CHARS)}"`, '', 'Respond with ONLY this JSON, nothing else:', `{"isCorrection": true or false, "category": "${CATEGORIES.join('" | "')}", "confidence": 0.0 to 1.0, "reason": "short explanation"}`);
58
+ return parts.join('\n');
59
+ }
60
+ export function parseModelResponse(text) {
61
+ const start = text.indexOf('{');
62
+ const end = text.lastIndexOf('}');
63
+ if (start === -1 || end <= start)
64
+ return undefined;
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(text.slice(start, end + 1));
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ if (typeof parsed['isCorrection'] !== 'boolean')
73
+ return undefined;
74
+ return {
75
+ isCorrection: parsed['isCorrection'],
76
+ category: typeof parsed['category'] === 'string' ? parsed['category'] : 'other',
77
+ confidence: typeof parsed['confidence'] === 'number' ? parsed['confidence'] : 0,
78
+ reason: typeof parsed['reason'] === 'string' ? parsed['reason'] : '',
79
+ };
80
+ }
81
+ export async function analyzeWithOllama(prompt, model, baseUrl = 'http://localhost:11434') {
82
+ const generation = await ollamaGenerate(prompt, model, baseUrl);
83
+ return { ...generation, parsed: parseModelResponse(generation.raw) };
84
+ }
@@ -0,0 +1,24 @@
1
+ // Shared minimal Ollama HTTP client for experiments. Direct fetch, no SDK.
2
+ export async function ollamaGenerate(prompt, model, baseUrl = 'http://localhost:11434') {
3
+ const startedAt = performance.now();
4
+ const response = await fetch(`${baseUrl}/api/generate`, {
5
+ method: 'POST',
6
+ headers: { 'content-type': 'application/json' },
7
+ body: JSON.stringify({
8
+ model,
9
+ prompt,
10
+ stream: false,
11
+ format: 'json',
12
+ options: { temperature: 0 },
13
+ }),
14
+ });
15
+ if (!response.ok) {
16
+ throw new Error(`Ollama responded ${response.status}: ${await response.text()}`);
17
+ }
18
+ const body = (await response.json());
19
+ return {
20
+ raw: body.response ?? '',
21
+ latencyMs: performance.now() - startedAt,
22
+ modelDurationMs: typeof body.total_duration === 'number' ? body.total_duration / 1_000_000 : undefined,
23
+ };
24
+ }
@@ -0,0 +1,46 @@
1
+ // EXPERIMENT — Task 3 checkpoint. Distills candidate personal-guidance rules
2
+ // from real user messages with a local Ollama model, so distillation quality
3
+ // can be judged before building the approve/store flow.
4
+ const MAX_MESSAGE_CHARS = 3_000;
5
+ export function buildDistillPrompt(message) {
6
+ const clipped = message.length > MAX_MESSAGE_CHARS ? `${message.slice(0, MAX_MESSAGE_CHARS)}...` : message;
7
+ return [
8
+ 'You extract personal working-preference rules that a user teaches an AI coding assistant.',
9
+ 'From the user message below, extract 0 to 3 rules the user wants the assistant to follow IN GENERAL across future work.',
10
+ 'A rule is a lasting preference, constraint, or way of working (e.g. "ask before making decisions", "keep answers short").',
11
+ 'One-off task instructions ("add a button", "fix this bug", "rename X") are NOT rules — return them nowhere.',
12
+ 'If the message contains no lasting guidance, return an empty rules array.',
13
+ '',
14
+ 'User message:',
15
+ `"${clipped}"`,
16
+ '',
17
+ 'Respond with ONLY this JSON, nothing else:',
18
+ '{"rules": [{"rule": "short imperative rule", "category": "one or two words", "confidence": 0.0 to 1.0}]}',
19
+ ].join('\n');
20
+ }
21
+ export function parseDistillResponse(text) {
22
+ const start = text.indexOf('{');
23
+ const end = text.lastIndexOf('}');
24
+ if (start === -1 || end <= start)
25
+ return [];
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(text.slice(start, end + 1));
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ if (!Array.isArray(parsed['rules']))
34
+ return [];
35
+ const rules = [];
36
+ for (const entry of parsed['rules']) {
37
+ if (typeof entry?.['rule'] !== 'string' || entry['rule'].trim() === '')
38
+ continue;
39
+ rules.push({
40
+ rule: entry['rule'].trim(),
41
+ category: typeof entry['category'] === 'string' ? entry['category'] : 'General',
42
+ confidence: typeof entry['confidence'] === 'number' ? entry['confidence'] : 0,
43
+ });
44
+ }
45
+ return rules;
46
+ }
@@ -0,0 +1,25 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { emptyStore, parseRuleStore, serializeRuleStore } from '../core/rule-store.js';
5
+ // PAI writes only to its own agent-neutral `.pai/` directories.
6
+ export function globalStorePath(home = homedir()) {
7
+ return join(home, '.pai', 'rules.json');
8
+ }
9
+ export function projectStorePath(cwd) {
10
+ return join(cwd, '.pai', 'rules.json');
11
+ }
12
+ export function readStore(path) {
13
+ let contents;
14
+ try {
15
+ contents = readFileSync(path, 'utf8');
16
+ }
17
+ catch {
18
+ return emptyStore();
19
+ }
20
+ return parseRuleStore(contents);
21
+ }
22
+ export function writeStore(path, store) {
23
+ mkdirSync(dirname(path), { recursive: true });
24
+ writeFileSync(path, serializeRuleStore(store), 'utf8');
25
+ }
@@ -0,0 +1,39 @@
1
+ # PAI — Principles & Validation Layers
2
+
3
+ ## Principles
4
+
5
+ 1. **Read-only toward agents' data.** PAI never modifies coding-agent files
6
+ (transcripts, configs, CLAUDE.md). PAI writes only to its own store
7
+ (`.pai/` in a project) — and to agent guidance files only as an explicit,
8
+ user-approved sync feature (future).
9
+ 2. **Approval gates knowledge.** Nothing becomes a stored rule without the
10
+ user approving it. Every stored or reported item carries evidence: the
11
+ user's actual words, session id, timestamp.
12
+ 3. **Candidates, not verdicts.** Detection output is labeled as possible /
13
+ candidate with confidence. PAI informs; it never blocks or controls.
14
+ 4. **Local-only.** Transcripts and derived data never leave the machine.
15
+ LLM analysis runs on a local model (Ollama). No cloud calls. Portability is
16
+ the user's move, not PAI's: `pai export` / `pai import` produce a portable,
17
+ versioned JSON store the user carries between machines. Imports merge
18
+ non-destructively; they never overwrite existing knowledge. A hosted
19
+ backend is a later swap, only if PAI ever becomes a multi-user service.
20
+ 5. **Degrade, never block.** A failed or unavailable layer reduces the
21
+ result and says so. Commands do not crash or guess when data is missing.
22
+ 6. **Outside the agent's context.** Anything that can be observed and
23
+ processed outside the coding agent's context stays outside it.
24
+
25
+ ## Validation layers
26
+
27
+ Each layer is independent, optional, and ordered cheap → expensive.
28
+ Command output states which layers ran (footer), so the user always knows
29
+ how much to trust a result.
30
+
31
+ | Layer | Validates | Cost | On failure / absence |
32
+ |---|---|---|---|
33
+ | L0 Data | transcripts exist, parseable, known format | free | report what was skipped; show partial results |
34
+ | L1 Deterministic | keyword corrections, tool failures | free | always available — the floor |
35
+ | L2 Semantic | local LLM: confirm corrections, distill rules, match rules | seconds, local | deliver L1 results labeled "unfiltered"; note skip |
36
+ | L3 Compliance | sessions vs stored rules → violations | uses L2 | no stored rules → report "nothing to audit" |
37
+ | L4 Trends (future) | violations over time; before/after a rule; model/config comparison | aggregation | needs accumulated history |
38
+
39
+ Adding a future validation = adding a row here and one module — not a framework.
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@lastboy/pai",
3
+ "version": "0.1.0",
4
+ "description": "PAI — Personal AI Supervisor: a local-first CLI that observes how you work with AI coding agents and turns it into knowledge you own",
5
+ "keywords": [
6
+ "cli",
7
+ "claude-code",
8
+ "ai",
9
+ "coding-agent",
10
+ "developer-tools",
11
+ "local-first",
12
+ "productivity"
13
+ ],
14
+ "homepage": "https://github.com/lastboy/pai#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/lastboy/pai/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/lastboy/pai.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Arik Lewin",
24
+ "type": "module",
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "bin": {
29
+ "pai": "./dist/cli/index.js"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "docs",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "dev": "tsx src/cli/index.ts",
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "test": "vitest run",
44
+ "typecheck": "tsc",
45
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
46
+ },
47
+ "dependencies": {
48
+ "commander": "^15.0.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^26.2.0",
52
+ "tsx": "^4.23.12",
53
+ "typescript": "^7.0.2",
54
+ "vitest": "^4.1.11"
55
+ }
56
+ }