@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 +21 -0
- package/README.md +189 -0
- package/dist/adapters/claude/discovery.js +99 -0
- package/dist/adapters/claude/events.js +71 -0
- package/dist/adapters/claude/guidelines.js +32 -0
- package/dist/adapters/claude/parser.js +117 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/program.js +303 -0
- package/dist/cli/render.js +113 -0
- package/dist/core/corrections.js +20 -0
- package/dist/core/guidelines.js +16 -0
- package/dist/core/review.js +27 -0
- package/dist/core/rule-store.js +0 -0
- package/dist/core/session.js +1 -0
- package/dist/core/status.js +6 -0
- package/dist/experiments/correction-analysis.js +84 -0
- package/dist/experiments/ollama.js +24 -0
- package/dist/experiments/rule-distillation.js +46 -0
- package/dist/persistence/rule-store-files.js +25 -0
- package/docs/principles.md +39 -0
- package/package.json +56 -0
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,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,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
|
+
}
|