@aakrit512/gatekeep 1.0.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/README.md +91 -0
- package/dist/ai/chat.js +33 -0
- package/dist/ai/contextUtils.js +79 -0
- package/dist/ai/openAiClient.js +72 -0
- package/dist/ai/summarizer.js +65 -0
- package/dist/cli/configStore.js +68 -0
- package/dist/cli/validation.js +45 -0
- package/dist/cli.js +74 -0
- package/dist/config.js +36 -0
- package/dist/functions/toolCallHandler.js +25 -0
- package/dist/functions/toolDefinitions.js +422 -0
- package/dist/functions/toolExecutor.js +1044 -0
- package/dist/prompts/initializationPrompt.js +46 -0
- package/dist/prompts/systemPrompt.js +72 -0
- package/dist/ui/projectDb.js +220 -0
- package/dist/ui/server.js +523 -0
- package/dist/ui/webAgent.js +170 -0
- package/package.json +47 -0
- package/ui/app.html +952 -0
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Gatekeep
|
|
2
|
+
|
|
3
|
+
A local, browser-based coding agent for reviewing and exploring projects with an OpenAI-compatible chat model. It runs on your machine, keeps project metadata and chat history in a local SQLite database, and gives the model a small set of read/review tools for inspecting a repository.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
- Starts a local web UI for saved projects and project chat.
|
|
8
|
+
- Initializes a project by creating `docs/intro.md` inside the target repository.
|
|
9
|
+
- Stores project metadata, chat messages, tool traces, token usage, and estimated costs locally.
|
|
10
|
+
- Lets you run provider/config/project doctor checks from the browser.
|
|
11
|
+
- Supports OpenAI-compatible providers through `AI_API_KEY`, `AI_BASE_URL`, and `MODEL_NAME`.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pnpm install
|
|
17
|
+
pnpm run build
|
|
18
|
+
pnpm link --global
|
|
19
|
+
gatekeep start --project /path/to/repo
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The UI starts at:
|
|
23
|
+
|
|
24
|
+
```txt
|
|
25
|
+
http://127.0.0.1:9808
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
You can also launch it for the current directory:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
gatekeep start --project .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
After publishing to npm, install and run it globally:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npm install --global @aakrit512/gatekeep
|
|
38
|
+
gatekeep start
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
Create a local `.env` file or configure values in the browser UI:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
AI_API_KEY=your_api_key
|
|
47
|
+
AI_BASE_URL=https://api.openai.com/v1
|
|
48
|
+
MODEL_NAME=gpt-4o
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Project metadata and chat history are stored in your OS app-support/config directory, not in the project being reviewed. API keys are masked in UI responses and are not stored in the project database.
|
|
52
|
+
|
|
53
|
+
## Folder Structure
|
|
54
|
+
|
|
55
|
+
```txt
|
|
56
|
+
.
|
|
57
|
+
├── src/
|
|
58
|
+
│ ├── ai/ OpenAI client setup, chat completion calls, context trimming, summarization helpers.
|
|
59
|
+
│ ├── cli/ User config storage and validation/doctor checks.
|
|
60
|
+
│ ├── functions/ Tool definitions and local tool execution used by the agent.
|
|
61
|
+
│ ├── prompts/ System and initialization prompts that guide repository review behavior.
|
|
62
|
+
│ ├── ui/ Local HTTP server, project database layer, and browser-agent workflow.
|
|
63
|
+
│ ├── cli.ts CLI entry point that starts the web UI.
|
|
64
|
+
│ └── config.ts Shared runtime configuration defaults and helpers.
|
|
65
|
+
├── ui/
|
|
66
|
+
│ └── app.html Browser UI document, styles, and client-side JavaScript.
|
|
67
|
+
├── package.json Package metadata, scripts, dependencies, and publish file list.
|
|
68
|
+
├── tsconfig.json TypeScript settings for development type checks.
|
|
69
|
+
├── tsconfig.build.json
|
|
70
|
+
│ TypeScript build settings for emitted `dist/` output.
|
|
71
|
+
├── pnpm-lock.yaml Locked dependency graph.
|
|
72
|
+
└── pnpm-workspace.yaml
|
|
73
|
+
pnpm workspace declaration.
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`dist/`, `node_modules/`, local `.env` files, and SQLite runtime files are generated locally and intentionally ignored by git.
|
|
77
|
+
|
|
78
|
+
## Scripts
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
pnpm run start # Run the TypeScript CLI directly with tsx.
|
|
82
|
+
pnpm run build # Compile TypeScript into dist/.
|
|
83
|
+
pnpm pack # Build and create the installable npm tarball.
|
|
84
|
+
pnpm run typecheck # Type-check without emitting files.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Development Notes
|
|
88
|
+
|
|
89
|
+
The browser UI is intentionally plain HTML so the package stays small and the server can serve it without a frontend build step. The server injects the curated fallback model list into `ui/app.html` at request time.
|
|
90
|
+
|
|
91
|
+
The agent tools are scoped to repository inspection and project initialization. During normal review/chat workflows, the model is guided to inspect files and diffs before answering.
|
package/dist/ai/chat.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getClient, getModelName } from './openAiClient.js';
|
|
2
|
+
import { tools } from '../functions/toolDefinitions.js';
|
|
3
|
+
import { preprocessMessages, estimateTokens } from './contextUtils.js';
|
|
4
|
+
import { numericEnv } from '../config.js';
|
|
5
|
+
const MAX_OUTPUT_TOKENS = numericEnv('AGENT_MAX_OUTPUT_TOKENS', 4096);
|
|
6
|
+
const MIN_OUTPUT_TOKENS = 512;
|
|
7
|
+
const TOKEN_BUDGET = numericEnv('AGENT_TOKEN_BUDGET', 32000);
|
|
8
|
+
// Exact prompt token count reported by the provider on the last call.
|
|
9
|
+
// null on the first call — falls back to the chars/4 estimate until the API tells us the real number.
|
|
10
|
+
let lastPromptTokens = null;
|
|
11
|
+
function computeMaxTokens(messages) {
|
|
12
|
+
// Prefer the exact value from the previous API response over the rough estimate.
|
|
13
|
+
// The next call will have slightly more tokens (new messages appended), so this is a
|
|
14
|
+
// conservative lower-bound — safe to use as the baseline for the remaining budget.
|
|
15
|
+
const inputTokens = lastPromptTokens ?? estimateTokens(messages);
|
|
16
|
+
const remaining = TOKEN_BUDGET - inputTokens;
|
|
17
|
+
return Math.max(MIN_OUTPUT_TOKENS, Math.min(MAX_OUTPUT_TOKENS, remaining));
|
|
18
|
+
}
|
|
19
|
+
export async function createChatCompletion(messages) {
|
|
20
|
+
const processed = preprocessMessages(messages);
|
|
21
|
+
const response = await getClient().chat.completions.create({
|
|
22
|
+
model: getModelName(),
|
|
23
|
+
messages: processed,
|
|
24
|
+
tools,
|
|
25
|
+
tool_choice: 'auto',
|
|
26
|
+
max_tokens: computeMaxTokens(processed),
|
|
27
|
+
});
|
|
28
|
+
// Store exact provider-reported prompt token count for the next call.
|
|
29
|
+
if (response.usage?.prompt_tokens) {
|
|
30
|
+
lastPromptTokens = response.usage.prompt_tokens;
|
|
31
|
+
}
|
|
32
|
+
return response;
|
|
33
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { numericEnv } from '../config.js';
|
|
2
|
+
// Strategy 1: per-message character caps (tool results capped tighter since they can be huge)
|
|
3
|
+
const MAX_MESSAGE_CHARS = numericEnv('AGENT_MAX_MESSAGE_CHARS', 8000);
|
|
4
|
+
const MAX_TOOL_RESULT_CHARS = numericEnv('AGENT_MAX_TOOL_RESULT_CHARS', 4000);
|
|
5
|
+
function truncate(text, maxChars) {
|
|
6
|
+
if (text.length <= maxChars)
|
|
7
|
+
return text;
|
|
8
|
+
return `${text.slice(0, maxChars)}\n… [truncated ${text.length - maxChars} chars]`;
|
|
9
|
+
}
|
|
10
|
+
// Strategy 5: normalize whitespace to eliminate noise before it reaches the model
|
|
11
|
+
function normalizeText(text) {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/[^\S\n]+$/gm, '') // strip trailing spaces per line
|
|
14
|
+
.replace(/\n{3,}/g, '\n\n') // collapse 3+ blank lines to 2
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
17
|
+
function transformContent(content, fn) {
|
|
18
|
+
if (typeof content === 'string')
|
|
19
|
+
return fn(content);
|
|
20
|
+
if (Array.isArray(content)) {
|
|
21
|
+
return content.map(part => part.type === 'text' && typeof part.text === 'string'
|
|
22
|
+
? { ...part, text: fn(part.text) }
|
|
23
|
+
: part);
|
|
24
|
+
}
|
|
25
|
+
return content;
|
|
26
|
+
}
|
|
27
|
+
function normalizeMessage(msg) {
|
|
28
|
+
// Keep system messages pristine — they carry the reviewer's critical instructions
|
|
29
|
+
if (msg.role === 'system' || !msg.content)
|
|
30
|
+
return msg;
|
|
31
|
+
return { ...msg, content: transformContent(msg.content, normalizeText) };
|
|
32
|
+
}
|
|
33
|
+
function truncateMessage(msg) {
|
|
34
|
+
if (msg.role === 'system' || !msg.content)
|
|
35
|
+
return msg;
|
|
36
|
+
const limit = msg.role === 'tool' ? MAX_TOOL_RESULT_CHARS : MAX_MESSAGE_CHARS;
|
|
37
|
+
return { ...msg, content: transformContent(msg.content, t => truncate(t, limit)) };
|
|
38
|
+
}
|
|
39
|
+
// Strategy 2/5: deduplicate identical tool results across history (e.g. the same file read twice)
|
|
40
|
+
function deduplicateToolResults(messages) {
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
return messages.map(msg => {
|
|
43
|
+
if (msg.role !== 'tool')
|
|
44
|
+
return msg;
|
|
45
|
+
const key = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
|
46
|
+
if (seen.has(key)) {
|
|
47
|
+
return { ...msg, content: '[duplicate tool result omitted — identical to a prior call]' };
|
|
48
|
+
}
|
|
49
|
+
seen.add(key);
|
|
50
|
+
return msg;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Preprocessing pipeline applied to the full message history before each API call.
|
|
55
|
+
* 1. Normalize whitespace (collapse blank lines, strip trailing spaces)
|
|
56
|
+
* 2. Deduplicate tool results (avoid re-sending identical file reads)
|
|
57
|
+
* 3. Truncate oversized messages (tool results capped tighter than user/assistant turns)
|
|
58
|
+
*
|
|
59
|
+
* System messages are never modified — they contain critical reviewer instructions.
|
|
60
|
+
*/
|
|
61
|
+
export function preprocessMessages(messages) {
|
|
62
|
+
return deduplicateToolResults(messages.map(normalizeMessage)).map(truncateMessage);
|
|
63
|
+
}
|
|
64
|
+
/** Rough token estimate: ~4 chars per token (used for dynamic max_tokens computation). */
|
|
65
|
+
export function estimateTokens(messages) {
|
|
66
|
+
let chars = 0;
|
|
67
|
+
for (const msg of messages) {
|
|
68
|
+
if (typeof msg.content === 'string') {
|
|
69
|
+
chars += msg.content.length;
|
|
70
|
+
}
|
|
71
|
+
else if (Array.isArray(msg.content)) {
|
|
72
|
+
for (const part of msg.content) {
|
|
73
|
+
if (part.type === 'text' && part.text)
|
|
74
|
+
chars += part.text.length;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return Math.ceil(chars / 4);
|
|
79
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { OpenAI } from 'openai';
|
|
2
|
+
import { withHeadroom } from 'headroom-ai/openai';
|
|
3
|
+
import { HeadroomClient } from 'headroom-ai';
|
|
4
|
+
import { floatEnv, getAppConfig, numericEnv } from '../config.js';
|
|
5
|
+
let cachedClient = null;
|
|
6
|
+
let cachedClientKey = '';
|
|
7
|
+
export function getModelName() {
|
|
8
|
+
return getAppConfig().model;
|
|
9
|
+
}
|
|
10
|
+
const headroomConfig = {
|
|
11
|
+
cacheAligner: {
|
|
12
|
+
enabled: true,
|
|
13
|
+
datePatterns: [
|
|
14
|
+
'Today is \\w+ \\d+, \\d{4}',
|
|
15
|
+
'Current date: .*',
|
|
16
|
+
'Current time: .*',
|
|
17
|
+
'Session ID: [a-f0-9-]+',
|
|
18
|
+
],
|
|
19
|
+
normalizeWhitespace: true,
|
|
20
|
+
collapseBlankLines: true,
|
|
21
|
+
},
|
|
22
|
+
cacheOptimizer: {
|
|
23
|
+
enabled: true,
|
|
24
|
+
autoDetectProvider: true,
|
|
25
|
+
minCacheableTokens: numericEnv('AGENT_MIN_CACHEABLE_TOKENS', 1024),
|
|
26
|
+
},
|
|
27
|
+
intelligentContext: {
|
|
28
|
+
enabled: true,
|
|
29
|
+
keepSystem: true,
|
|
30
|
+
keepLastTurns: numericEnv('AGENT_IC_KEEP_LAST_TURNS', 6),
|
|
31
|
+
outputBufferTokens: numericEnv('AGENT_IC_OUTPUT_BUFFER_TOKENS', 4000),
|
|
32
|
+
useImportanceScoring: true,
|
|
33
|
+
scoringWeights: {
|
|
34
|
+
recency: 0.2,
|
|
35
|
+
semanticSimilarity: 0.2,
|
|
36
|
+
toinImportance: 0.25,
|
|
37
|
+
errorIndicator: 0.15,
|
|
38
|
+
forwardReference: 0.15,
|
|
39
|
+
tokenDensity: 0.05,
|
|
40
|
+
},
|
|
41
|
+
toinIntegration: true,
|
|
42
|
+
recencyDecayRate: floatEnv('AGENT_RECENCY_DECAY_RATE', 0.1),
|
|
43
|
+
compressThreshold: floatEnv('AGENT_COMPRESS_THRESHOLD', 0.1),
|
|
44
|
+
},
|
|
45
|
+
rollingWindow: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
keepSystem: true,
|
|
48
|
+
keepLastTurns: numericEnv('AGENT_RW_KEEP_LAST_TURNS', 3),
|
|
49
|
+
outputBufferTokens: numericEnv('AGENT_RW_OUTPUT_BUFFER_TOKENS', 4000),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
export function getClient() {
|
|
53
|
+
const config = getAppConfig();
|
|
54
|
+
const clientKey = `${config.apiKey}:${config.baseUrl}:${config.model}`;
|
|
55
|
+
if (cachedClient && cachedClientKey === clientKey) {
|
|
56
|
+
return cachedClient;
|
|
57
|
+
}
|
|
58
|
+
const baseClient = new OpenAI({
|
|
59
|
+
apiKey: config.apiKey,
|
|
60
|
+
baseURL: config.baseUrl,
|
|
61
|
+
});
|
|
62
|
+
cachedClient = withHeadroom(baseClient, {
|
|
63
|
+
model: config.model,
|
|
64
|
+
tokenBudget: numericEnv('AGENT_TOKEN_BUDGET', 32000),
|
|
65
|
+
client: new HeadroomClient({
|
|
66
|
+
config: headroomConfig,
|
|
67
|
+
fallback: true,
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
cachedClientKey = clientKey;
|
|
71
|
+
return cachedClient;
|
|
72
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getClient, getModelName } from './openAiClient.js';
|
|
2
|
+
import { preprocessMessages } from './contextUtils.js';
|
|
3
|
+
import { numericEnv } from '../config.js';
|
|
4
|
+
// Number of recent messages (not turn-pairs) to keep verbatim for conversational continuity.
|
|
5
|
+
// Generous multiplier: each turn can be user + assistant + N tool messages.
|
|
6
|
+
const KEEP_RECENT_MESSAGES = numericEnv('AGENT_SUMMARY_KEEP_RECENT_MESSAGES', 12);
|
|
7
|
+
const SUMMARIZE_SYSTEM_PROMPT = `You are a conversation compressor for a read-only code reviewer agent.
|
|
8
|
+
Your output replaces the full conversation history to cut token usage — it must preserve every piece of actionable context a reviewer needs to continue work seamlessly.
|
|
9
|
+
|
|
10
|
+
Include ALL of the following that appear in the history:
|
|
11
|
+
- **Files examined**: path, inferred purpose, key structural findings
|
|
12
|
+
- **Tool calls and outcomes**: what was called, what was returned (material facts only)
|
|
13
|
+
- **Review findings issued**: severity, file:line, concise description, recommended fix
|
|
14
|
+
- **Reviewer decisions and positions**: architectural opinions, standards violations flagged
|
|
15
|
+
- **Unresolved items**: files not yet examined, pending follow-ups, open questions
|
|
16
|
+
|
|
17
|
+
Format rules:
|
|
18
|
+
- Use bullet points under clear ## headings
|
|
19
|
+
- Be exhaustive on facts; ruthless on prose — omit filler, summaries of summaries, and pleasantries
|
|
20
|
+
- Output ONLY the summary body — no preamble, no sign-off`;
|
|
21
|
+
/**
|
|
22
|
+
* Compresses conversation history by summarising older turns with the AI model.
|
|
23
|
+
*
|
|
24
|
+
* Preservation order:
|
|
25
|
+
* 1. All original system messages (reviewer persona + project intro) — never touched
|
|
26
|
+
* 2. A generated [Condensed History] system message replacing older turns
|
|
27
|
+
* 3. The most recent AGENT_SUMMARY_KEEP_RECENT_MESSAGES messages verbatim
|
|
28
|
+
*
|
|
29
|
+
* If summarisation fails (network error, empty response, etc.) the original history
|
|
30
|
+
* is returned unchanged — callers should not crash on summarisation failure.
|
|
31
|
+
*/
|
|
32
|
+
export async function summarizeHistory(messages) {
|
|
33
|
+
const systemMessages = messages.filter(m => m.role === 'system');
|
|
34
|
+
const turnMessages = messages.filter(m => m.role !== 'system');
|
|
35
|
+
const toSummarize = turnMessages.slice(0, -KEEP_RECENT_MESSAGES);
|
|
36
|
+
const recentMessages = turnMessages.slice(-KEEP_RECENT_MESSAGES);
|
|
37
|
+
// Not enough older history to justify an API call
|
|
38
|
+
if (toSummarize.length < 4)
|
|
39
|
+
return messages;
|
|
40
|
+
const payload = [
|
|
41
|
+
{ role: 'system', content: SUMMARIZE_SYSTEM_PROMPT },
|
|
42
|
+
...preprocessMessages(toSummarize),
|
|
43
|
+
{ role: 'user', content: 'Produce the condensed history now.' },
|
|
44
|
+
];
|
|
45
|
+
let summaryText;
|
|
46
|
+
try {
|
|
47
|
+
const response = await getClient().chat.completions.create({
|
|
48
|
+
model: getModelName(),
|
|
49
|
+
messages: payload,
|
|
50
|
+
max_tokens: numericEnv('AGENT_SUMMARY_MAX_TOKENS', 2048),
|
|
51
|
+
});
|
|
52
|
+
summaryText = response.choices[0].message.content?.trim() ?? '';
|
|
53
|
+
if (!summaryText)
|
|
54
|
+
return messages; // empty response — degrade gracefully
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Summarisation failed — return original history unchanged
|
|
58
|
+
return messages;
|
|
59
|
+
}
|
|
60
|
+
const summaryMessage = {
|
|
61
|
+
role: 'system',
|
|
62
|
+
content: `[Condensed History — replaces ${toSummarize.length} earlier messages]\n\n${summaryText}`,
|
|
63
|
+
};
|
|
64
|
+
return [...systemMessages, summaryMessage, ...recentMessages];
|
|
65
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { cleanString, DEFAULT_BASE_URL, DEFAULT_MODEL, DEFAULT_PROVIDER, } from '../config.js';
|
|
5
|
+
export const CONFIG_KEYS = ['provider', 'apiKey', 'model', 'baseUrl'];
|
|
6
|
+
export function appConfigDir() {
|
|
7
|
+
if (process.platform === 'darwin') {
|
|
8
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'gatekeep');
|
|
9
|
+
}
|
|
10
|
+
if (process.platform === 'win32') {
|
|
11
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'gatekeep');
|
|
12
|
+
}
|
|
13
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'gatekeep');
|
|
14
|
+
}
|
|
15
|
+
export function getConfigPath() {
|
|
16
|
+
return path.join(appConfigDir(), 'config.json');
|
|
17
|
+
}
|
|
18
|
+
function isProvider(value) {
|
|
19
|
+
return value === DEFAULT_PROVIDER;
|
|
20
|
+
}
|
|
21
|
+
function normalizeConfig(value) {
|
|
22
|
+
if (!value || typeof value !== 'object')
|
|
23
|
+
return {};
|
|
24
|
+
const raw = value;
|
|
25
|
+
const config = {};
|
|
26
|
+
if (isProvider(raw.provider))
|
|
27
|
+
config.provider = raw.provider;
|
|
28
|
+
if (typeof raw.apiKey === 'string')
|
|
29
|
+
config.apiKey = raw.apiKey;
|
|
30
|
+
if (typeof raw.model === 'string')
|
|
31
|
+
config.model = raw.model;
|
|
32
|
+
if (typeof raw.baseUrl === 'string')
|
|
33
|
+
config.baseUrl = raw.baseUrl;
|
|
34
|
+
return config;
|
|
35
|
+
}
|
|
36
|
+
export function readUserConfig() {
|
|
37
|
+
const configPath = getConfigPath();
|
|
38
|
+
if (!fs.existsSync(configPath))
|
|
39
|
+
return {};
|
|
40
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
41
|
+
return normalizeConfig(JSON.parse(raw));
|
|
42
|
+
}
|
|
43
|
+
export function writeUserConfig(config) {
|
|
44
|
+
const configPath = getConfigPath();
|
|
45
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
46
|
+
const minimal = normalizeConfig(config);
|
|
47
|
+
fs.writeFileSync(configPath, `${JSON.stringify(minimal, null, 2)}\n`, {
|
|
48
|
+
encoding: 'utf-8',
|
|
49
|
+
mode: 0o600,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export function resolveConfig(flags, stored = readUserConfig()) {
|
|
53
|
+
return {
|
|
54
|
+
provider: stored.provider || DEFAULT_PROVIDER,
|
|
55
|
+
apiKey: cleanString(flags.apiKey) || cleanString(process.env.AI_API_KEY) || cleanString(stored.apiKey) || '',
|
|
56
|
+
model: cleanString(flags.model) || cleanString(process.env.MODEL_NAME) || cleanString(stored.model) || DEFAULT_MODEL,
|
|
57
|
+
baseUrl: cleanString(flags.baseUrl) || cleanString(process.env.AI_BASE_URL) || cleanString(stored.baseUrl) || DEFAULT_BASE_URL,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function setConfigValue(config, key, value) {
|
|
61
|
+
if (key === 'provider') {
|
|
62
|
+
if (!isProvider(value)) {
|
|
63
|
+
throw new Error(`Unsupported provider "${value}". Only "${DEFAULT_PROVIDER}" is supported.`);
|
|
64
|
+
}
|
|
65
|
+
return { ...config, provider: value };
|
|
66
|
+
}
|
|
67
|
+
return { ...config, [key]: value };
|
|
68
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
export function maskSecret(value) {
|
|
4
|
+
if (!value)
|
|
5
|
+
return '(not set)';
|
|
6
|
+
const visible = value.slice(-4);
|
|
7
|
+
const prefix = value.includes('-') ? `${value.split('-')[0]}-` : '';
|
|
8
|
+
return `${prefix}...${visible}`;
|
|
9
|
+
}
|
|
10
|
+
export function validateConfig(config) {
|
|
11
|
+
const errors = [];
|
|
12
|
+
if (!config.apiKey.trim()) {
|
|
13
|
+
errors.push('API key is missing. Add it in the web UI settings.');
|
|
14
|
+
}
|
|
15
|
+
if (!config.model.trim()) {
|
|
16
|
+
errors.push('Model is missing. Add it in the web UI settings.');
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(config.baseUrl);
|
|
20
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
21
|
+
errors.push('Base URL must start with http:// or https://.');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
errors.push(`Base URL is invalid: ${config.baseUrl}`);
|
|
26
|
+
}
|
|
27
|
+
return errors;
|
|
28
|
+
}
|
|
29
|
+
export function validateProject(project) {
|
|
30
|
+
const errors = [];
|
|
31
|
+
const resolved = path.resolve(project);
|
|
32
|
+
if (!fs.existsSync(resolved)) {
|
|
33
|
+
errors.push(`Project path does not exist: ${resolved}`);
|
|
34
|
+
}
|
|
35
|
+
else if (!fs.statSync(resolved).isDirectory()) {
|
|
36
|
+
errors.push(`Project path is not a directory: ${resolved}`);
|
|
37
|
+
}
|
|
38
|
+
return errors;
|
|
39
|
+
}
|
|
40
|
+
export function validateNodeRuntime() {
|
|
41
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
42
|
+
if (Number.isFinite(major) && major >= 18)
|
|
43
|
+
return [];
|
|
44
|
+
return [`Node.js 18 or newer is required. Current version: ${process.versions.node}`];
|
|
45
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import 'dotenv/config';
|
|
3
|
+
import { startUiServer } from './ui/server.js';
|
|
4
|
+
const LEGACY_COMMANDS = new Set(['ui', 'init', 'config', 'doctor', 'run']);
|
|
5
|
+
const START_COMMAND = 'start';
|
|
6
|
+
function printUsage() {
|
|
7
|
+
console.log(`gatekeep
|
|
8
|
+
|
|
9
|
+
Starts the local web UI.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
gatekeep start [--project PATH] [--port PORT] [--host HOST]
|
|
13
|
+
gatekeep [--project PATH] [--port PORT] [--host HOST]
|
|
14
|
+
|
|
15
|
+
All setup, config, doctor checks, initialization, and chat workflows are available in the browser UI.
|
|
16
|
+
`);
|
|
17
|
+
}
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const options = {};
|
|
20
|
+
for (let index = 0; index < argv.length; index++) {
|
|
21
|
+
const arg = argv[index];
|
|
22
|
+
if (arg === '--help' || arg === '-h' || arg === 'help') {
|
|
23
|
+
options.help = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!arg.startsWith('--')) {
|
|
27
|
+
if (arg === START_COMMAND)
|
|
28
|
+
continue;
|
|
29
|
+
if (LEGACY_COMMANDS.has(arg))
|
|
30
|
+
continue;
|
|
31
|
+
if (!options.project)
|
|
32
|
+
options.project = arg;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const [rawKey, inlineValue] = arg.slice(2).split('=', 2);
|
|
36
|
+
const value = inlineValue ?? argv[++index];
|
|
37
|
+
if (!value || value.startsWith('--')) {
|
|
38
|
+
throw new Error(`Flag --${rawKey} requires a value.`);
|
|
39
|
+
}
|
|
40
|
+
if (rawKey === 'project') {
|
|
41
|
+
options.project = value;
|
|
42
|
+
}
|
|
43
|
+
else if (rawKey === 'port') {
|
|
44
|
+
const port = Number(value);
|
|
45
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
46
|
+
throw new Error(`Invalid port "${value}".`);
|
|
47
|
+
}
|
|
48
|
+
options.port = port;
|
|
49
|
+
}
|
|
50
|
+
else if (rawKey === 'host') {
|
|
51
|
+
options.host = value;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
throw new Error(`Unknown flag --${rawKey}. Use --help for web launcher options.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return options;
|
|
58
|
+
}
|
|
59
|
+
async function main() {
|
|
60
|
+
const options = parseArgs(process.argv.slice(2));
|
|
61
|
+
if (options.help) {
|
|
62
|
+
printUsage();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await startUiServer({
|
|
66
|
+
project: options.project,
|
|
67
|
+
port: options.port,
|
|
68
|
+
host: options.host,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
main().catch(error => {
|
|
72
|
+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const DEFAULT_PROVIDER = 'openai-compatible';
|
|
2
|
+
export const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
3
|
+
export const DEFAULT_MODEL = 'gpt-4o';
|
|
4
|
+
export function cleanString(value) {
|
|
5
|
+
const trimmed = value?.trim();
|
|
6
|
+
return trimmed ? trimmed : undefined;
|
|
7
|
+
}
|
|
8
|
+
/** Parse a strictly-positive integer/float from an environment variable. */
|
|
9
|
+
export function numericEnv(name, fallback) {
|
|
10
|
+
const v = Number(process.env[name]);
|
|
11
|
+
return Number.isFinite(v) && v > 0 ? v : fallback;
|
|
12
|
+
}
|
|
13
|
+
/** Parse any finite float from an environment variable (allows 0 and negatives). */
|
|
14
|
+
export function floatEnv(name, fallback) {
|
|
15
|
+
const v = Number(process.env[name]);
|
|
16
|
+
return Number.isFinite(v) ? v : fallback;
|
|
17
|
+
}
|
|
18
|
+
function readEnvConfig() {
|
|
19
|
+
return {
|
|
20
|
+
provider: DEFAULT_PROVIDER,
|
|
21
|
+
apiKey: cleanString(process.env.AI_API_KEY) || '',
|
|
22
|
+
baseUrl: cleanString(process.env.AI_BASE_URL) || DEFAULT_BASE_URL,
|
|
23
|
+
model: cleanString(process.env.MODEL_NAME) || DEFAULT_MODEL,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
let activeConfig = readEnvConfig();
|
|
27
|
+
export function configureApp(config) {
|
|
28
|
+
activeConfig = {
|
|
29
|
+
...activeConfig,
|
|
30
|
+
...config,
|
|
31
|
+
provider: config.provider ?? activeConfig.provider,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function getAppConfig() {
|
|
35
|
+
return activeConfig;
|
|
36
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { executeTool } from './toolExecutor.js';
|
|
2
|
+
function parseToolArguments(rawArguments) {
|
|
3
|
+
try {
|
|
4
|
+
const parsed = JSON.parse(rawArguments);
|
|
5
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function isFunctionToolCall(toolCall) {
|
|
12
|
+
return toolCall.type === 'function';
|
|
13
|
+
}
|
|
14
|
+
export function runToolCall(toolCall) {
|
|
15
|
+
const args = parseToolArguments(toolCall.function.arguments);
|
|
16
|
+
const content = executeTool(toolCall.function.name, args);
|
|
17
|
+
return {
|
|
18
|
+
args,
|
|
19
|
+
message: {
|
|
20
|
+
role: 'tool',
|
|
21
|
+
tool_call_id: toolCall.id,
|
|
22
|
+
content,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|