stakeout-cli 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.
Files changed (56) hide show
  1. package/LICENSE +131 -0
  2. package/README.md +152 -0
  3. package/dist/commands/chat.d.ts +5 -0
  4. package/dist/commands/chat.js +162 -0
  5. package/dist/commands/clear.d.ts +7 -0
  6. package/dist/commands/clear.js +89 -0
  7. package/dist/commands/config.d.ts +10 -0
  8. package/dist/commands/config.js +64 -0
  9. package/dist/commands/dashboard.d.ts +5 -0
  10. package/dist/commands/dashboard.js +9 -0
  11. package/dist/commands/digest.d.ts +6 -0
  12. package/dist/commands/digest.js +113 -0
  13. package/dist/commands/export.d.ts +8 -0
  14. package/dist/commands/export.js +118 -0
  15. package/dist/commands/hook.d.ts +6 -0
  16. package/dist/commands/hook.js +57 -0
  17. package/dist/commands/init.d.ts +6 -0
  18. package/dist/commands/init.js +70 -0
  19. package/dist/commands/log.d.ts +9 -0
  20. package/dist/commands/log.js +103 -0
  21. package/dist/commands/note.d.ts +9 -0
  22. package/dist/commands/note.js +48 -0
  23. package/dist/commands/record.d.ts +6 -0
  24. package/dist/commands/record.js +106 -0
  25. package/dist/commands/repo.d.ts +7 -0
  26. package/dist/commands/repo.js +60 -0
  27. package/dist/commands/search.d.ts +5 -0
  28. package/dist/commands/search.js +69 -0
  29. package/dist/commands/stats.d.ts +1 -0
  30. package/dist/commands/stats.js +99 -0
  31. package/dist/commands/tag.d.ts +8 -0
  32. package/dist/commands/tag.js +61 -0
  33. package/dist/commands/tui.d.ts +1 -0
  34. package/dist/commands/tui.js +5 -0
  35. package/dist/commands/watch.d.ts +6 -0
  36. package/dist/commands/watch.js +101 -0
  37. package/dist/index.d.ts +2 -0
  38. package/dist/index.js +195 -0
  39. package/dist/lib/config.d.ts +5 -0
  40. package/dist/lib/config.js +51 -0
  41. package/dist/lib/database.d.ts +31 -0
  42. package/dist/lib/database.js +222 -0
  43. package/dist/lib/diff.d.ts +3 -0
  44. package/dist/lib/diff.js +118 -0
  45. package/dist/lib/summarizer.d.ts +2 -0
  46. package/dist/lib/summarizer.js +90 -0
  47. package/dist/tui/App.d.ts +1 -0
  48. package/dist/tui/App.js +125 -0
  49. package/dist/types/index.d.ts +38 -0
  50. package/dist/types/index.js +1 -0
  51. package/dist/web/public/app.js +387 -0
  52. package/dist/web/public/index.html +131 -0
  53. package/dist/web/public/styles.css +571 -0
  54. package/dist/web/server.d.ts +1 -0
  55. package/dist/web/server.js +402 -0
  56. package/package.json +69 -0
@@ -0,0 +1,51 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ const CONFIG_DIR = join(homedir(), '.stakeout');
5
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
6
+ const DEFAULT_CONFIG = {
7
+ llm_provider: 'ollama',
8
+ ollama_model: 'llama3',
9
+ ollama_host: 'http://localhost:11434',
10
+ openai_model: 'gpt-4o-mini',
11
+ watched_path: process.cwd(),
12
+ ignore_patterns: [
13
+ '*.lock',
14
+ 'package-lock.json',
15
+ 'yarn.lock',
16
+ 'pnpm-lock.yaml',
17
+ 'node_modules/**',
18
+ 'dist/**',
19
+ 'build/**',
20
+ '.git/**',
21
+ '*.min.js',
22
+ '*.min.css',
23
+ '*.map'
24
+ ]
25
+ };
26
+ export function ensureConfigDir() {
27
+ if (!existsSync(CONFIG_DIR)) {
28
+ mkdirSync(CONFIG_DIR, { recursive: true });
29
+ }
30
+ }
31
+ export function loadConfig() {
32
+ ensureConfigDir();
33
+ if (!existsSync(CONFIG_FILE)) {
34
+ saveConfig(DEFAULT_CONFIG);
35
+ return DEFAULT_CONFIG;
36
+ }
37
+ try {
38
+ const raw = readFileSync(CONFIG_FILE, 'utf-8');
39
+ return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
40
+ }
41
+ catch {
42
+ return DEFAULT_CONFIG;
43
+ }
44
+ }
45
+ export function saveConfig(config) {
46
+ ensureConfigDir();
47
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
48
+ }
49
+ export function getConfigPath() {
50
+ return CONFIG_FILE;
51
+ }
@@ -0,0 +1,31 @@
1
+ import Database from 'better-sqlite3';
2
+ import type { StakeoutEntry } from '../types/index.js';
3
+ export declare function getDb(): Database.Database;
4
+ export declare function insertEntry(entry: StakeoutEntry): number;
5
+ export declare function updateEntry(id: number, updates: Partial<StakeoutEntry>): void;
6
+ export declare function getEntry(id: number): StakeoutEntry | null;
7
+ export declare function getEntries(options: {
8
+ limit?: number;
9
+ since?: string;
10
+ path?: string;
11
+ repo?: string;
12
+ favorites?: boolean;
13
+ tag?: string;
14
+ }): StakeoutEntry[];
15
+ export declare function entryExists(diffHash: string): boolean;
16
+ export declare function addRepo(path: string, name: string): void;
17
+ export declare function getRepos(): {
18
+ id: number;
19
+ path: string;
20
+ name: string;
21
+ added_at: string;
22
+ last_recorded: string | null;
23
+ }[];
24
+ export declare function updateRepoLastRecorded(path: string): void;
25
+ export declare function addChatMessage(role: 'user' | 'assistant', content: string): void;
26
+ export declare function getChatHistory(limit?: number): {
27
+ role: string;
28
+ content: string;
29
+ }[];
30
+ export declare function clearChatHistory(): void;
31
+ export declare function closeDb(): void;
@@ -0,0 +1,222 @@
1
+ import Database from 'better-sqlite3';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ import { ensureConfigDir } from './config.js';
5
+ const DB_PATH = join(homedir(), '.stakeout', 'stakeout.db');
6
+ let db = null;
7
+ export function getDb() {
8
+ if (!db) {
9
+ ensureConfigDir();
10
+ db = new Database(DB_PATH);
11
+ initSchema();
12
+ migrateSchema();
13
+ createIndexes();
14
+ }
15
+ return db;
16
+ }
17
+ function initSchema() {
18
+ const database = db;
19
+ // Create base tables (without new columns for backwards compat)
20
+ database.exec(`
21
+ CREATE TABLE IF NOT EXISTS entries (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ timestamp TEXT NOT NULL,
24
+ files_changed TEXT NOT NULL,
25
+ directories TEXT NOT NULL,
26
+ summary TEXT NOT NULL,
27
+ diff_hash TEXT NOT NULL,
28
+ commit_hash TEXT,
29
+ commit_message TEXT
30
+ );
31
+
32
+ CREATE TABLE IF NOT EXISTS repos (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ path TEXT UNIQUE NOT NULL,
35
+ name TEXT NOT NULL,
36
+ added_at TEXT NOT NULL,
37
+ last_recorded TEXT
38
+ );
39
+
40
+ CREATE TABLE IF NOT EXISTS chat_history (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ timestamp TEXT NOT NULL,
43
+ role TEXT NOT NULL,
44
+ content TEXT NOT NULL
45
+ );
46
+ `);
47
+ }
48
+ function createIndexes() {
49
+ const database = db;
50
+ // Create indexes after migrations have added all columns
51
+ try {
52
+ database.exec(`
53
+ CREATE INDEX IF NOT EXISTS idx_timestamp ON entries(timestamp);
54
+ CREATE INDEX IF NOT EXISTS idx_diff_hash ON entries(diff_hash);
55
+ `);
56
+ // Only create these if columns exist
57
+ const columns = database.prepare("PRAGMA table_info(entries)").all();
58
+ const columnNames = columns.map(c => c.name);
59
+ if (columnNames.includes('favorite')) {
60
+ database.exec(`CREATE INDEX IF NOT EXISTS idx_favorite ON entries(favorite);`);
61
+ }
62
+ if (columnNames.includes('repo_path')) {
63
+ database.exec(`CREATE INDEX IF NOT EXISTS idx_repo_path ON entries(repo_path);`);
64
+ }
65
+ }
66
+ catch (e) {
67
+ // Indexes may already exist
68
+ }
69
+ }
70
+ function migrateSchema() {
71
+ const database = db;
72
+ // Add new columns if they don't exist (for existing databases)
73
+ const columns = database.prepare("PRAGMA table_info(entries)").all();
74
+ const columnNames = columns.map(c => c.name);
75
+ if (!columnNames.includes('tags')) {
76
+ database.exec("ALTER TABLE entries ADD COLUMN tags TEXT DEFAULT '[]'");
77
+ }
78
+ if (!columnNames.includes('favorite')) {
79
+ database.exec("ALTER TABLE entries ADD COLUMN favorite INTEGER DEFAULT 0");
80
+ }
81
+ if (!columnNames.includes('notes')) {
82
+ database.exec("ALTER TABLE entries ADD COLUMN notes TEXT DEFAULT ''");
83
+ }
84
+ if (!columnNames.includes('repo_path')) {
85
+ database.exec("ALTER TABLE entries ADD COLUMN repo_path TEXT DEFAULT ''");
86
+ }
87
+ if (!columnNames.includes('is_breaking')) {
88
+ database.exec("ALTER TABLE entries ADD COLUMN is_breaking INTEGER DEFAULT 0");
89
+ }
90
+ }
91
+ export function insertEntry(entry) {
92
+ const database = getDb();
93
+ const stmt = database.prepare(`
94
+ INSERT INTO entries (timestamp, files_changed, directories, summary, diff_hash, commit_hash, commit_message, tags, favorite, notes, repo_path, is_breaking)
95
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
96
+ `);
97
+ const result = stmt.run(entry.timestamp, JSON.stringify(entry.files_changed), JSON.stringify(entry.directories), entry.summary, entry.diff_hash, entry.commit_hash ?? null, entry.commit_message ?? null, JSON.stringify(entry.tags || []), entry.favorite ? 1 : 0, entry.notes || '', entry.repo_path || '', entry.is_breaking ? 1 : 0);
98
+ return result.lastInsertRowid;
99
+ }
100
+ export function updateEntry(id, updates) {
101
+ const database = getDb();
102
+ const fields = [];
103
+ const values = [];
104
+ if (updates.tags !== undefined) {
105
+ fields.push('tags = ?');
106
+ values.push(JSON.stringify(updates.tags));
107
+ }
108
+ if (updates.favorite !== undefined) {
109
+ fields.push('favorite = ?');
110
+ values.push(updates.favorite ? 1 : 0);
111
+ }
112
+ if (updates.notes !== undefined) {
113
+ fields.push('notes = ?');
114
+ values.push(updates.notes);
115
+ }
116
+ if (updates.is_breaking !== undefined) {
117
+ fields.push('is_breaking = ?');
118
+ values.push(updates.is_breaking ? 1 : 0);
119
+ }
120
+ if (fields.length === 0)
121
+ return;
122
+ values.push(id);
123
+ const stmt = database.prepare(`UPDATE entries SET ${fields.join(', ')} WHERE id = ?`);
124
+ stmt.run(...values);
125
+ }
126
+ export function getEntry(id) {
127
+ const database = getDb();
128
+ const row = database.prepare('SELECT * FROM entries WHERE id = ?').get(id);
129
+ if (!row)
130
+ return null;
131
+ return parseEntryRow(row);
132
+ }
133
+ export function getEntries(options) {
134
+ const database = getDb();
135
+ let query = 'SELECT * FROM entries WHERE 1=1';
136
+ const params = [];
137
+ if (options.since) {
138
+ query += ' AND timestamp >= ?';
139
+ params.push(options.since);
140
+ }
141
+ if (options.path) {
142
+ query += ' AND (files_changed LIKE ? OR directories LIKE ?)';
143
+ params.push(`%${options.path}%`, `%${options.path}%`);
144
+ }
145
+ if (options.repo) {
146
+ query += ' AND repo_path = ?';
147
+ params.push(options.repo);
148
+ }
149
+ if (options.favorites) {
150
+ query += ' AND favorite = 1';
151
+ }
152
+ if (options.tag) {
153
+ query += ' AND tags LIKE ?';
154
+ params.push(`%"${options.tag}"%`);
155
+ }
156
+ query += ' ORDER BY timestamp DESC';
157
+ if (options.limit) {
158
+ query += ' LIMIT ?';
159
+ params.push(options.limit);
160
+ }
161
+ const stmt = database.prepare(query);
162
+ const rows = stmt.all(...params);
163
+ return rows.map(parseEntryRow);
164
+ }
165
+ function parseEntryRow(row) {
166
+ return {
167
+ id: row.id,
168
+ timestamp: row.timestamp,
169
+ files_changed: JSON.parse(row.files_changed),
170
+ directories: JSON.parse(row.directories),
171
+ summary: row.summary,
172
+ diff_hash: row.diff_hash,
173
+ commit_hash: row.commit_hash,
174
+ commit_message: row.commit_message,
175
+ tags: row.tags ? JSON.parse(row.tags) : [],
176
+ favorite: row.favorite === 1,
177
+ notes: row.notes || '',
178
+ repo_path: row.repo_path || '',
179
+ is_breaking: row.is_breaking === 1
180
+ };
181
+ }
182
+ export function entryExists(diffHash) {
183
+ const database = getDb();
184
+ const stmt = database.prepare('SELECT 1 FROM entries WHERE diff_hash = ?');
185
+ return stmt.get(diffHash) !== undefined;
186
+ }
187
+ // Repo management
188
+ export function addRepo(path, name) {
189
+ const database = getDb();
190
+ const stmt = database.prepare(`
191
+ INSERT OR REPLACE INTO repos (path, name, added_at, last_recorded)
192
+ VALUES (?, ?, ?, NULL)
193
+ `);
194
+ stmt.run(path, name, new Date().toISOString());
195
+ }
196
+ export function getRepos() {
197
+ const database = getDb();
198
+ return database.prepare('SELECT * FROM repos ORDER BY name').all();
199
+ }
200
+ export function updateRepoLastRecorded(path) {
201
+ const database = getDb();
202
+ database.prepare('UPDATE repos SET last_recorded = ? WHERE path = ?').run(new Date().toISOString(), path);
203
+ }
204
+ // Chat history
205
+ export function addChatMessage(role, content) {
206
+ const database = getDb();
207
+ database.prepare('INSERT INTO chat_history (timestamp, role, content) VALUES (?, ?, ?)').run(new Date().toISOString(), role, content);
208
+ }
209
+ export function getChatHistory(limit = 20) {
210
+ const database = getDb();
211
+ return database.prepare('SELECT role, content FROM chat_history ORDER BY id DESC LIMIT ?').all(limit).reverse();
212
+ }
213
+ export function clearChatHistory() {
214
+ const database = getDb();
215
+ database.prepare('DELETE FROM chat_history').run();
216
+ }
217
+ export function closeDb() {
218
+ if (db) {
219
+ db.close();
220
+ db = null;
221
+ }
222
+ }
@@ -0,0 +1,3 @@
1
+ import type { DiffResult } from '../types/index.js';
2
+ export declare function collectDiff(targetPath?: string): Promise<DiffResult | null>;
3
+ export declare function collectLastCommit(targetPath?: string): Promise<DiffResult | null>;
@@ -0,0 +1,118 @@
1
+ import { simpleGit } from 'simple-git';
2
+ import { createHash } from 'crypto';
3
+ import { loadConfig } from './config.js';
4
+ export async function collectDiff(targetPath) {
5
+ const config = loadConfig();
6
+ const path = targetPath ?? config.watched_path;
7
+ const git = simpleGit(path);
8
+ // Check if it's a git repo
9
+ const isRepo = await git.checkIsRepo();
10
+ if (!isRepo) {
11
+ console.error('Not a git repository:', path);
12
+ return null;
13
+ }
14
+ // Get the diff - staged + unstaged changes
15
+ const diff = await git.diff();
16
+ const stagedDiff = await git.diff(['--staged']);
17
+ const combinedDiff = diff + stagedDiff;
18
+ if (!combinedDiff.trim()) {
19
+ // No uncommitted changes, check last commit
20
+ const log = await git.log({ maxCount: 1 });
21
+ if (!log.latest) {
22
+ return null;
23
+ }
24
+ const lastCommitDiff = await git.diff([`${log.latest.hash}^`, log.latest.hash]);
25
+ if (!lastCommitDiff.trim()) {
26
+ return null;
27
+ }
28
+ return parseDiff(lastCommitDiff, log.latest.hash, log.latest.message);
29
+ }
30
+ return parseDiff(combinedDiff);
31
+ }
32
+ export async function collectLastCommit(targetPath) {
33
+ const config = loadConfig();
34
+ const path = targetPath ?? config.watched_path;
35
+ const git = simpleGit(path);
36
+ const isRepo = await git.checkIsRepo();
37
+ if (!isRepo) {
38
+ console.error('Not a git repository:', path);
39
+ return null;
40
+ }
41
+ const log = await git.log({ maxCount: 1 });
42
+ if (!log.latest) {
43
+ return null;
44
+ }
45
+ // Get diff of the last commit
46
+ const commitDiff = await git.diff([`${log.latest.hash}^`, log.latest.hash]).catch(() => {
47
+ // First commit has no parent, get the full tree
48
+ return git.show([log.latest.hash, '--format=', '--name-only']);
49
+ });
50
+ if (!commitDiff.trim()) {
51
+ return null;
52
+ }
53
+ return parseDiff(commitDiff, log.latest.hash, log.latest.message);
54
+ }
55
+ function parseDiff(diffText, commitHash, commitMessage) {
56
+ const config = loadConfig();
57
+ // Extract file paths from diff
58
+ const fileMatches = diffText.matchAll(/^(?:diff --git a\/(.+?) b\/|(?:\+\+\+|---) [ab]\/(.+))$/gm);
59
+ const filesSet = new Set();
60
+ for (const match of fileMatches) {
61
+ const file = match[1] || match[2];
62
+ if (file && !shouldIgnore(file, config.ignore_patterns)) {
63
+ filesSet.add(file);
64
+ }
65
+ }
66
+ const files = Array.from(filesSet);
67
+ // Extract unique directories
68
+ const dirsSet = new Set();
69
+ for (const file of files) {
70
+ const parts = file.split('/');
71
+ if (parts.length > 1) {
72
+ // Add immediate parent and top-level dir
73
+ dirsSet.add(parts[0]);
74
+ if (parts.length > 2) {
75
+ dirsSet.add(parts.slice(0, 2).join('/'));
76
+ }
77
+ }
78
+ }
79
+ const directories = Array.from(dirsSet);
80
+ // Create hash for deduplication
81
+ const diffHash = createHash('sha256').update(diffText).digest('hex').slice(0, 16);
82
+ // Filter the diff text to remove ignored files
83
+ const filteredDiff = filterDiffText(diffText, config.ignore_patterns);
84
+ return {
85
+ files,
86
+ directories,
87
+ diff_text: filteredDiff,
88
+ diff_hash: diffHash,
89
+ commit_hash: commitHash,
90
+ commit_message: commitMessage
91
+ };
92
+ }
93
+ function shouldIgnore(filePath, patterns) {
94
+ for (const pattern of patterns) {
95
+ if (matchPattern(filePath, pattern)) {
96
+ return true;
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+ function matchPattern(filePath, pattern) {
102
+ // Simple glob matching
103
+ const regexPattern = pattern
104
+ .replace(/\./g, '\\.')
105
+ .replace(/\*\*/g, '.*')
106
+ .replace(/\*/g, '[^/]*');
107
+ return new RegExp(`^${regexPattern}$`).test(filePath);
108
+ }
109
+ function filterDiffText(diffText, ignorePatterns) {
110
+ const chunks = diffText.split(/(?=^diff --git)/m);
111
+ const filteredChunks = chunks.filter(chunk => {
112
+ const match = chunk.match(/^diff --git a\/(.+?) b\//);
113
+ if (!match)
114
+ return true;
115
+ return !shouldIgnore(match[1], ignorePatterns);
116
+ });
117
+ return filteredChunks.join('');
118
+ }
@@ -0,0 +1,2 @@
1
+ import type { DiffResult } from '../types/index.js';
2
+ export declare function summarize(diff: DiffResult): Promise<string>;
@@ -0,0 +1,90 @@
1
+ import { Ollama } from 'ollama';
2
+ import OpenAI from 'openai';
3
+ import { loadConfig } from './config.js';
4
+ const SYSTEM_PROMPT = `You are a code change analyst. Your job is to summarize code changes concisely.
5
+
6
+ Rules:
7
+ - Focus on the WHY and IMPACT, not just what files changed
8
+ - Be concise: 1-3 sentences max
9
+ - Use plain language, avoid jargon
10
+ - If there's a commit message, use it as context for intent
11
+ - Group related changes together conceptually
12
+ - Mention breaking changes or risky modifications explicitly
13
+
14
+ Example good summary:
15
+ "Added rate limiting to the auth endpoints to prevent brute force attacks. Also fixed a bug where expired tokens weren't being rejected properly."
16
+
17
+ Example bad summary:
18
+ "Modified auth.ts, added rate-limiter.ts, updated config.json"`;
19
+ export async function summarize(diff) {
20
+ const config = loadConfig();
21
+ const userPrompt = buildPrompt(diff);
22
+ if (config.llm_provider === 'ollama') {
23
+ return summarizeWithOllama(userPrompt, config.ollama_model, config.ollama_host);
24
+ }
25
+ else {
26
+ return summarizeWithOpenAI(userPrompt, config.openai_model, config.openai_api_key);
27
+ }
28
+ }
29
+ function buildPrompt(diff) {
30
+ let prompt = '';
31
+ if (diff.commit_message) {
32
+ prompt += `Commit message: "${diff.commit_message}"\n\n`;
33
+ }
34
+ prompt += `Files changed (${diff.files.length}):\n`;
35
+ prompt += diff.files.map(f => ` - ${f}`).join('\n');
36
+ prompt += '\n\n';
37
+ if (diff.directories.length > 0) {
38
+ prompt += `Areas affected: ${diff.directories.join(', ')}\n\n`;
39
+ }
40
+ // Truncate diff if too long (keep first 4000 chars)
41
+ const maxDiffLength = 4000;
42
+ let diffText = diff.diff_text;
43
+ if (diffText.length > maxDiffLength) {
44
+ diffText = diffText.slice(0, maxDiffLength) + '\n\n... (diff truncated)';
45
+ }
46
+ prompt += `Diff:\n\`\`\`\n${diffText}\n\`\`\``;
47
+ return prompt;
48
+ }
49
+ async function summarizeWithOllama(prompt, model, host) {
50
+ try {
51
+ const ollama = new Ollama({ host });
52
+ const response = await ollama.chat({
53
+ model,
54
+ messages: [
55
+ { role: 'system', content: SYSTEM_PROMPT },
56
+ { role: 'user', content: prompt }
57
+ ],
58
+ options: {
59
+ temperature: 0.3
60
+ }
61
+ });
62
+ return response.message.content.trim();
63
+ }
64
+ catch (error) {
65
+ if (error.code === 'ECONNREFUSED') {
66
+ throw new Error(`Cannot connect to Ollama at ${host}. Is Ollama running?\n` +
67
+ `Start it with: ollama serve\n` +
68
+ `Or switch to OpenAI with: stakeout config --provider openai`);
69
+ }
70
+ throw error;
71
+ }
72
+ }
73
+ async function summarizeWithOpenAI(prompt, model, apiKey) {
74
+ if (!apiKey) {
75
+ throw new Error('OpenAI API key not configured.\n' +
76
+ 'Set it with: stakeout config --openai-key YOUR_KEY\n' +
77
+ 'Or switch to Ollama with: stakeout config --provider ollama');
78
+ }
79
+ const openai = new OpenAI({ apiKey });
80
+ const response = await openai.chat.completions.create({
81
+ model,
82
+ messages: [
83
+ { role: 'system', content: SYSTEM_PROMPT },
84
+ { role: 'user', content: prompt }
85
+ ],
86
+ temperature: 0.3,
87
+ max_tokens: 500
88
+ });
89
+ return response.choices[0]?.message?.content?.trim() ?? 'Unable to generate summary';
90
+ }
@@ -0,0 +1 @@
1
+ export declare function App(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,125 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Box, Text, useInput, useApp } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ import { getEntries, getDb } from '../lib/database.js';
6
+ import { collectDiff, collectLastCommit } from '../lib/diff.js';
7
+ import { summarize } from '../lib/summarizer.js';
8
+ import { insertEntry } from '../lib/database.js';
9
+ export function App() {
10
+ const { exit } = useApp();
11
+ const [entries, setEntries] = useState([]);
12
+ const [stats, setStats] = useState({ total: 0, today: 0, thisWeek: 0 });
13
+ const [selectedIndex, setSelectedIndex] = useState(0);
14
+ const [isRecording, setIsRecording] = useState(false);
15
+ const [message, setMessage] = useState('');
16
+ const [showHelp, setShowHelp] = useState(false);
17
+ const loadData = () => {
18
+ const db = getDb();
19
+ const totalEntries = db.prepare('SELECT COUNT(*) as count FROM entries').get();
20
+ const today = new Date();
21
+ today.setHours(0, 0, 0, 0);
22
+ const todayEntries = db.prepare('SELECT COUNT(*) as count FROM entries WHERE timestamp >= ?').get(today.toISOString());
23
+ const weekAgo = new Date();
24
+ weekAgo.setDate(weekAgo.getDate() - 7);
25
+ const weekEntries = db.prepare('SELECT COUNT(*) as count FROM entries WHERE timestamp >= ?').get(weekAgo.toISOString());
26
+ setStats({
27
+ total: totalEntries.count,
28
+ today: todayEntries.count,
29
+ thisWeek: weekEntries.count
30
+ });
31
+ setEntries(getEntries({ limit: 10 }));
32
+ };
33
+ useEffect(() => {
34
+ loadData();
35
+ }, []);
36
+ const showMessage = (msg) => {
37
+ setMessage(msg);
38
+ setTimeout(() => setMessage(''), 3000);
39
+ };
40
+ const doRecord = async (lastCommit) => {
41
+ setIsRecording(true);
42
+ setMessage(lastCommit ? 'Recording last commit...' : 'Recording changes...');
43
+ try {
44
+ const diff = lastCommit ? await collectLastCommit() : await collectDiff();
45
+ if (!diff) {
46
+ showMessage('No changes detected');
47
+ setIsRecording(false);
48
+ return;
49
+ }
50
+ const summary = await summarize(diff);
51
+ const entry = {
52
+ timestamp: new Date().toISOString(),
53
+ files_changed: diff.files,
54
+ directories: diff.directories,
55
+ summary,
56
+ diff_hash: diff.diff_hash,
57
+ commit_hash: diff.commit_hash,
58
+ commit_message: diff.commit_message
59
+ };
60
+ insertEntry(entry);
61
+ loadData();
62
+ showMessage('Recorded successfully!');
63
+ }
64
+ catch (err) {
65
+ showMessage(`Error: ${err.message}`);
66
+ }
67
+ setIsRecording(false);
68
+ };
69
+ useInput((input, key) => {
70
+ if (showHelp) {
71
+ setShowHelp(false);
72
+ return;
73
+ }
74
+ if (input === 'q') {
75
+ exit();
76
+ return;
77
+ }
78
+ if (input === '?') {
79
+ setShowHelp(true);
80
+ return;
81
+ }
82
+ if (input === 'r' && !isRecording) {
83
+ doRecord(false);
84
+ return;
85
+ }
86
+ if (input === 'c' && !isRecording) {
87
+ doRecord(true);
88
+ return;
89
+ }
90
+ if (input === 'f') {
91
+ loadData();
92
+ showMessage('Refreshed');
93
+ return;
94
+ }
95
+ if (key.upArrow && selectedIndex > 0) {
96
+ setSelectedIndex(selectedIndex - 1);
97
+ }
98
+ if (key.downArrow && selectedIndex < entries.length - 1) {
99
+ setSelectedIndex(selectedIndex + 1);
100
+ }
101
+ });
102
+ if (showHelp) {
103
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "STAKEOUT - Keyboard Shortcuts" }) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "r" }), " - Record current changes"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "c" }), " - Record last commit"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "f" }), " - Refresh data"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u2191/\u2193" }), " - Navigate entries"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "?" }), " - Show/hide help"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "q" }), " - Quit"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press any key to close" }) })] }));
104
+ }
105
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "green", children: "\uD83D\uDD0D STAKEOUT" }), _jsx(Text, { dimColor: true, children: " - Command Center" }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { dimColor: true, children: "[?] help [q] quit" })] }), _jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Box, { children: [_jsx(Text, { color: "green", bold: true, children: stats.total }), _jsx(Text, { dimColor: true, children: " total" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: stats.today }), _jsx(Text, { dimColor: true, children: " today" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "yellow", bold: true, children: stats.thisWeek }), _jsx(Text, { dimColor: true, children: " this week" })] })] }), _jsx(Box, { marginBottom: 1, gap: 2, children: isRecording ? (_jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), _jsxs(Text, { color: "yellow", children: [" ", message] })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "[r] record" }), _jsx(Text, { dimColor: true, children: "[c] last commit" }), _jsx(Text, { dimColor: true, children: "[f] refresh" })] })) }), message && !isRecording && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "cyan", children: message }) })), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(60) }) }), entries.length === 0 ? (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "No intel recorded. Press [r] to record changes." }) })) : (_jsx(Box, { flexDirection: "column", children: entries.map((entry, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 1, borderStyle: i === selectedIndex ? 'single' : undefined, borderColor: "green", children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { color: "green", children: ["#", entry.id] }), _jsx(Text, { dimColor: true, children: formatTime(entry.timestamp) }), entry.commit_hash && (_jsx(Text, { color: "magenta", children: entry.commit_hash.slice(0, 7) }))] }), _jsx(Text, { wrap: "truncate-end", children: entry.summary.length > 80
106
+ ? entry.summary.slice(0, 80) + '...'
107
+ : entry.summary })] }, entry.id))) }))] }));
108
+ }
109
+ function formatTime(timestamp) {
110
+ const date = new Date(timestamp);
111
+ const now = new Date();
112
+ const diffMs = now.getTime() - date.getTime();
113
+ const diffMins = Math.floor(diffMs / 60000);
114
+ const diffHours = Math.floor(diffMins / 60);
115
+ const diffDays = Math.floor(diffHours / 24);
116
+ if (diffMins < 1)
117
+ return 'just now';
118
+ if (diffMins < 60)
119
+ return `${diffMins}m ago`;
120
+ if (diffHours < 24)
121
+ return `${diffHours}h ago`;
122
+ if (diffDays < 7)
123
+ return `${diffDays}d ago`;
124
+ return date.toLocaleDateString();
125
+ }