project-knowledge 0.1.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +201 -58
- package/_site/_test/ai-profile-test.js +59 -1
- package/_site/_test/baseline-schema-test.js +4 -3
- package/_site/_test/claude-workbench-test.js +72 -0
- package/_site/_test/draft-apply-test.js +12 -6
- package/_site/_test/kb-v2-templates-test.js +31 -43
- package/_site/_test/knowledge-store-logs-supervision-test.js +143 -0
- package/_site/_test/package-startup-test.js +108 -0
- package/_site/_test/project-control-panel-task14-test.js +151 -0
- package/_site/_test/task15-20-integration-test.js +194 -0
- package/_site/_test/task15-20-ui-flow-test.js +144 -0
- package/_site/_test/ui-smoke-test.js +2 -2
- package/_site/index.html +1640 -90
- package/_site/lib/ai-adapter.js +3 -3
- package/_site/lib/ai-workspace.js +120 -0
- package/_site/lib/analysis-orchestrator.js +117 -32
- package/_site/lib/claude-cli-runner.js +862 -0
- package/_site/lib/context-pack-builder.js +19 -11
- package/_site/lib/draft-apply.js +80 -31
- package/_site/lib/index-builder.js +100 -0
- package/_site/lib/job-orchestrator.js +15 -11
- package/_site/lib/kb-v3.js +188 -0
- package/_site/lib/kb-validator.js +84 -0
- package/_site/lib/knowledge-store.js +141 -0
- package/_site/lib/llm-client.js +103 -56
- package/_site/lib/prompt-registry.js +102 -0
- package/_site/lib/structured-logger.js +120 -0
- package/_site/lib/supervision.js +103 -0
- package/_site/server.js +887 -30
- package/_site/vendor/tailwind-browser.js +947 -0
- package/_site/vendor/vue.global.prod.js +9 -0
- package/ai-profiles.json +13 -3
- package/bin/project-knowledge.js +51 -0
- package/docs/development-progress.md +141 -0
- package/package.json +11 -2
- package/scripts/gen-commit-doc.ps1 +1 -1
- package/scripts/list-features.ps1 +1 -1
- package/scripts/register-scheduled-task.bat +3 -1
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
const fs = require('fs');
|
|
39
39
|
const path = require('path');
|
|
40
|
+
const { isV3Kb, V3_TOP_LEVEL } = require('./kb-v3');
|
|
40
41
|
|
|
41
42
|
const MANIFEST_SCHEMA = 'kb-manifest/v1';
|
|
42
43
|
const REQUIRED_DRAFT_AREAS = ['_ai/drafts/', '_ai/runs/', '_ai/context-packs/'];
|
|
@@ -57,6 +58,65 @@ function isInsideDraftArea(rel) {
|
|
|
57
58
|
return norm === '_ai' || norm.startsWith('_ai/');
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
function walkTrustedFiles(root, dir = root, prefix = '') {
|
|
62
|
+
const out = [];
|
|
63
|
+
let entries = [];
|
|
64
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
65
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
66
|
+
if (entry.name.startsWith('.')) continue;
|
|
67
|
+
if (entry.name === '_ai' || entry.name === '_meta' || entry.name === 'node_modules') continue;
|
|
68
|
+
const rel = (prefix ? `${prefix}/` : '') + entry.name;
|
|
69
|
+
const full = path.join(dir, entry.name);
|
|
70
|
+
if (entry.isDirectory()) out.push(...walkTrustedFiles(root, full, rel));
|
|
71
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
72
|
+
out.push({ path: rel, content: fs.readFileSync(full, 'utf-8'), size: fs.statSync(full).size });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateV3Kb(kbPath, info) {
|
|
79
|
+
const errors = [];
|
|
80
|
+
const warnings = [];
|
|
81
|
+
const required = [
|
|
82
|
+
['README.md', 'file'],
|
|
83
|
+
['GOAL.md', 'file'],
|
|
84
|
+
['ARCHITECTURE.md', 'file'],
|
|
85
|
+
['modules', 'dir'],
|
|
86
|
+
['changes', 'dir'],
|
|
87
|
+
['modules/00-index.md', 'file'],
|
|
88
|
+
['changes/00-index.md', 'file'],
|
|
89
|
+
];
|
|
90
|
+
for (const [rel, kind] of required) {
|
|
91
|
+
const abs = path.join(kbPath, rel);
|
|
92
|
+
if (!fs.existsSync(abs)) {
|
|
93
|
+
errors.push(`${rel} missing`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const stat = fs.statSync(abs);
|
|
97
|
+
if (kind === 'file' && !stat.isFile()) errors.push(`${rel} must be a file`);
|
|
98
|
+
if (kind === 'dir' && !stat.isDirectory()) errors.push(`${rel} must be a directory`);
|
|
99
|
+
}
|
|
100
|
+
const top = fs.readdirSync(kbPath).filter(name => !name.startsWith('.')).sort();
|
|
101
|
+
const unexpected = top.filter(name => !V3_TOP_LEVEL.includes(name));
|
|
102
|
+
if (unexpected.length) errors.push(`unexpected top-level KB v3 items: ${unexpected.join(', ')}`);
|
|
103
|
+
if (fs.existsSync(path.join(kbPath, '_ai'))) errors.push('_ai must not live inside a KB v3 directory');
|
|
104
|
+
if (fs.existsSync(path.join(kbPath, 'kb-manifest.json'))) errors.push('kb-manifest.json must not exist in KB v3');
|
|
105
|
+
info.schemaVersion = 'v3';
|
|
106
|
+
info.manifestExists = false;
|
|
107
|
+
info.goalStatus = fs.existsSync(path.join(kbPath, 'GOAL.md')) ? 'accepted-or-draft' : 'missing';
|
|
108
|
+
info.analysisStatus = fs.existsSync(path.join(kbPath, 'ARCHITECTURE.md')) ? 'present' : 'missing';
|
|
109
|
+
info.trustedKnowledgeEntries = walkTrustedFiles(kbPath).length;
|
|
110
|
+
return {
|
|
111
|
+
ok: errors.length === 0,
|
|
112
|
+
status: errors.length ? 422 : 200,
|
|
113
|
+
info,
|
|
114
|
+
errors,
|
|
115
|
+
warnings,
|
|
116
|
+
manifest: null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
60
120
|
function validateKb(kbPath) {
|
|
61
121
|
const errors = [];
|
|
62
122
|
const warnings = [];
|
|
@@ -77,6 +137,10 @@ function validateKb(kbPath) {
|
|
|
77
137
|
return { ok: false, status: 400, error: `kbPath is not a directory: ${kbPath}` };
|
|
78
138
|
}
|
|
79
139
|
|
|
140
|
+
if (isV3Kb(kbPath)) {
|
|
141
|
+
return validateV3Kb(kbPath, info);
|
|
142
|
+
}
|
|
143
|
+
|
|
80
144
|
const manifestPath = path.join(kbPath, 'kb-manifest.json');
|
|
81
145
|
if (!fs.existsSync(manifestPath)) {
|
|
82
146
|
return { ok: false, status: 422, error: 'kb-manifest.json missing', info, errors: ['manifest missing'] };
|
|
@@ -186,6 +250,25 @@ function buildPrContextPack(kbPath) {
|
|
|
186
250
|
const v = validateKb(kbPath);
|
|
187
251
|
if (!v.ok) return { ok: false, status: v.status || 422, error: 'kb invalid', validation: v };
|
|
188
252
|
const manifest = v.manifest;
|
|
253
|
+
if (v.info && v.info.schemaVersion === 'v3') {
|
|
254
|
+
const files = walkTrustedFiles(kbPath);
|
|
255
|
+
const readFile = rel => files.find(item => item.path === rel) || null;
|
|
256
|
+
return {
|
|
257
|
+
ok: true,
|
|
258
|
+
pack: {
|
|
259
|
+
schema: 'pr-context-pack/v1',
|
|
260
|
+
generatedAt: new Date().toISOString(),
|
|
261
|
+
project: path.basename(kbPath),
|
|
262
|
+
goal: readFile('GOAL.md'),
|
|
263
|
+
analysis: readFile('ARCHITECTURE.md'),
|
|
264
|
+
indexes: {
|
|
265
|
+
modules: readFile('modules/00-index.md'),
|
|
266
|
+
changes: readFile('changes/00-index.md'),
|
|
267
|
+
},
|
|
268
|
+
trustedKnowledge: files,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
189
272
|
const read = rel => {
|
|
190
273
|
if (!rel || !existsAny(kbPath, rel)) return null;
|
|
191
274
|
const abs = path.resolve(kbPath, rel);
|
|
@@ -221,4 +304,5 @@ module.exports = {
|
|
|
221
304
|
REQUIRED_DRAFT_AREAS,
|
|
222
305
|
validateKb,
|
|
223
306
|
buildPrContextPack,
|
|
307
|
+
walkTrustedFiles,
|
|
224
308
|
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const SCHEMA = 'knowledge-store/v1';
|
|
5
|
+
|
|
6
|
+
function defaultConfig(appRoot) {
|
|
7
|
+
return {
|
|
8
|
+
schema: SCHEMA,
|
|
9
|
+
rootPath: path.join(appRoot, 'projects'),
|
|
10
|
+
git: {
|
|
11
|
+
enabled: false,
|
|
12
|
+
remoteUrl: '',
|
|
13
|
+
branch: 'main',
|
|
14
|
+
autoCommit: false,
|
|
15
|
+
autoPush: false,
|
|
16
|
+
},
|
|
17
|
+
configured: false,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeConfig(input, appRoot) {
|
|
22
|
+
const base = defaultConfig(appRoot);
|
|
23
|
+
const source = input && typeof input === 'object' ? input : {};
|
|
24
|
+
const git = source.git && typeof source.git === 'object' ? source.git : {};
|
|
25
|
+
return {
|
|
26
|
+
schema: SCHEMA,
|
|
27
|
+
rootPath: path.resolve(source.rootPath || base.rootPath),
|
|
28
|
+
git: {
|
|
29
|
+
enabled: git.enabled === true,
|
|
30
|
+
remoteUrl: typeof git.remoteUrl === 'string' ? git.remoteUrl : '',
|
|
31
|
+
branch: typeof git.branch === 'string' && git.branch.trim() ? git.branch.trim() : 'main',
|
|
32
|
+
autoCommit: git.autoCommit === true,
|
|
33
|
+
autoPush: git.autoPush === true,
|
|
34
|
+
},
|
|
35
|
+
configured: source.configured === true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readConfig(configPath, appRoot) {
|
|
40
|
+
if (!fs.existsSync(configPath)) return defaultConfig(appRoot);
|
|
41
|
+
try {
|
|
42
|
+
return normalizeConfig(JSON.parse(fs.readFileSync(configPath, 'utf-8')), appRoot);
|
|
43
|
+
} catch {
|
|
44
|
+
return defaultConfig(appRoot);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeConfig(configPath, appRoot, config) {
|
|
49
|
+
const normalized = normalizeConfig({ ...config, configured: true }, appRoot);
|
|
50
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
51
|
+
fs.writeFileSync(configPath, JSON.stringify(normalized, null, 2) + '\n', 'utf-8');
|
|
52
|
+
return normalized;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateRoot(rootPath) {
|
|
56
|
+
if (!rootPath || typeof rootPath !== 'string') {
|
|
57
|
+
return { ok: false, error: 'rootPath is required' };
|
|
58
|
+
}
|
|
59
|
+
const resolved = path.resolve(rootPath);
|
|
60
|
+
try {
|
|
61
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
62
|
+
const stat = fs.statSync(resolved);
|
|
63
|
+
if (!stat.isDirectory()) return { ok: false, rootPath: resolved, error: 'rootPath is not a directory' };
|
|
64
|
+
return { ok: true, rootPath: resolved };
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return { ok: false, rootPath: resolved, error: e.message };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function defaultProjectKbPath(slug, configPath, appRoot) {
|
|
71
|
+
const cfg = readConfig(configPath, appRoot);
|
|
72
|
+
return path.join(cfg.rootPath, slug);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isInside(child, parent) {
|
|
76
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child));
|
|
77
|
+
return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function copyDir(src, dest) {
|
|
81
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
82
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
83
|
+
const from = path.join(src, entry.name);
|
|
84
|
+
const to = path.join(dest, entry.name);
|
|
85
|
+
if (entry.isDirectory()) copyDir(from, to);
|
|
86
|
+
else if (entry.isFile()) fs.copyFileSync(from, to);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildMigrationPlan({ projects, appRoot, storeConfig }) {
|
|
91
|
+
const legacyRoot = path.join(appRoot, 'projects');
|
|
92
|
+
const items = [];
|
|
93
|
+
for (const [slug, cfg] of Object.entries(projects || {})) {
|
|
94
|
+
const current = path.resolve(cfg.kbPath || path.join(legacyRoot, slug));
|
|
95
|
+
if (!isInside(current, legacyRoot)) continue;
|
|
96
|
+
const target = path.join(storeConfig.rootPath, slug);
|
|
97
|
+
if (path.resolve(current) === path.resolve(target)) continue;
|
|
98
|
+
items.push({
|
|
99
|
+
slug,
|
|
100
|
+
from: current,
|
|
101
|
+
to: target,
|
|
102
|
+
exists: fs.existsSync(current),
|
|
103
|
+
targetExists: fs.existsSync(target),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return items;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function migrateProjects({ projects, appRoot, storeConfig, overwrite = false, move = false }) {
|
|
110
|
+
const plan = buildMigrationPlan({ projects, appRoot, storeConfig });
|
|
111
|
+
const migrated = [];
|
|
112
|
+
for (const item of plan) {
|
|
113
|
+
if (!item.exists) {
|
|
114
|
+
projects[item.slug].kbPath = item.to;
|
|
115
|
+
migrated.push({ ...item, copied: false, reason: 'source missing; path updated only' });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (item.targetExists && !overwrite) {
|
|
119
|
+
migrated.push({ ...item, copied: false, skipped: true, reason: 'target exists' });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (item.targetExists && overwrite) fs.rmSync(item.to, { recursive: true, force: true });
|
|
123
|
+
copyDir(item.from, item.to);
|
|
124
|
+
if (move) fs.rmSync(item.from, { recursive: true, force: true });
|
|
125
|
+
projects[item.slug].kbPath = item.to;
|
|
126
|
+
migrated.push({ ...item, copied: true, moved: !!move });
|
|
127
|
+
}
|
|
128
|
+
return { plan, migrated };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
SCHEMA,
|
|
133
|
+
defaultConfig,
|
|
134
|
+
readConfig,
|
|
135
|
+
writeConfig,
|
|
136
|
+
validateRoot,
|
|
137
|
+
defaultProjectKbPath,
|
|
138
|
+
buildMigrationPlan,
|
|
139
|
+
migrateProjects,
|
|
140
|
+
isInside,
|
|
141
|
+
};
|
package/_site/lib/llm-client.js
CHANGED
|
@@ -1,56 +1,76 @@
|
|
|
1
1
|
// _site/lib/llm-client.js
|
|
2
2
|
//
|
|
3
|
-
// Tiny Anthropic Messages API client. Zero npm deps;
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// or any other Anthropic-compatible endpoint):
|
|
3
|
+
// Tiny Anthropic Messages API client. Zero npm deps; supports HTTPS and HTTP
|
|
4
|
+
// Anthropic-compatible endpoints. Connection settings are resolved from an AI
|
|
5
|
+
// profile first, then environment variables as fallback:
|
|
7
6
|
//
|
|
8
|
-
// ANTHROPIC_BASE_URL
|
|
9
|
-
// ANTHROPIC_AUTH_TOKEN
|
|
10
|
-
// ANTHROPIC_MODEL
|
|
11
|
-
// ANTHROPIC_VERSION
|
|
7
|
+
// profile.baseUrl or ANTHROPIC_BASE_URL
|
|
8
|
+
// profile.apiKey or ANTHROPIC_AUTH_TOKEN
|
|
9
|
+
// profile.model or ANTHROPIC_MODEL
|
|
10
|
+
// profile.version or ANTHROPIC_VERSION
|
|
11
|
+
// profile.timeoutMs or API_TIMEOUT_MS
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
13
|
+
// This lets the local UI persist model settings in ai-profiles.json while still
|
|
14
|
+
// keeping env vars useful for scripts and temporary overrides.
|
|
15
15
|
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const http = require('http');
|
|
16
19
|
const https = require('https');
|
|
17
20
|
const { URL } = require('url');
|
|
18
21
|
|
|
19
22
|
const DEFAULT_BASE = 'https://api.anthropic.com';
|
|
20
23
|
const DEFAULT_VERSION = '2023-06-01';
|
|
21
24
|
const DEFAULT_MODEL = 'claude-haiku-4-5';
|
|
25
|
+
const AI_PROFILES_PATH = path.resolve(__dirname, '..', '..', 'ai-profiles.json');
|
|
22
26
|
|
|
23
|
-
function
|
|
27
|
+
function readProfilesFile() {
|
|
28
|
+
try {
|
|
29
|
+
const cfg = JSON.parse(fs.readFileSync(AI_PROFILES_PATH, 'utf-8'));
|
|
30
|
+
return Array.isArray(cfg.profiles) ? cfg.profiles : [];
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function findProfile(profileId) {
|
|
37
|
+
if (!profileId) return null;
|
|
38
|
+
return readProfilesFile().find(profile => profile && profile.id === profileId) || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readConfig(options = {}) {
|
|
42
|
+
const profileId = typeof options === 'string' ? options : options.profileId;
|
|
43
|
+
const profile = findProfile(profileId) || {};
|
|
44
|
+
const apiKeyFromEnvName = profile.apiKeyEnv ? process.env[profile.apiKeyEnv] : '';
|
|
45
|
+
const timeoutMs = Number(profile.timeoutMs || process.env.API_TIMEOUT_MS || 60_000);
|
|
24
46
|
return {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
47
|
+
profileId: profile.id || profileId || null,
|
|
48
|
+
baseUrl: profile.baseUrl || profile.apiBaseUrl || profile.anthropicBaseUrl || process.env.ANTHROPIC_BASE_URL || DEFAULT_BASE,
|
|
49
|
+
apiKey: profile.apiKey || profile.authToken || profile.anthropicAuthToken || apiKeyFromEnvName || process.env.ANTHROPIC_AUTH_TOKEN || '',
|
|
50
|
+
model: profile.model || process.env.ANTHROPIC_MODEL || DEFAULT_MODEL,
|
|
51
|
+
version: profile.version || profile.anthropicVersion || process.env.ANTHROPIC_VERSION || DEFAULT_VERSION,
|
|
52
|
+
temperature: profile.temperature,
|
|
53
|
+
maxTokens: profile.maxTokens,
|
|
54
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 60_000,
|
|
29
55
|
};
|
|
30
56
|
}
|
|
31
57
|
|
|
58
|
+
function buildRequestPath(base, p) {
|
|
59
|
+
if (!p.startsWith('/')) return p;
|
|
60
|
+
const basePath = base.pathname.endsWith('/') ? base.pathname.slice(0, -1) : base.pathname;
|
|
61
|
+
return basePath + p;
|
|
62
|
+
}
|
|
63
|
+
|
|
32
64
|
function postJson({ baseUrl, path: p, body, apiKey, version, timeoutMs = 60_000 }) {
|
|
33
65
|
return new Promise((resolve, reject) => {
|
|
34
|
-
let url;
|
|
35
66
|
try {
|
|
36
|
-
// Allow baseUrl to contain a path prefix (e.g. "https://api.x.com/anthropic").
|
|
37
|
-
// We resolve `p` relative to that base, but the result must be treated
|
|
38
|
-
// as the final URL — no further prefix insertion.
|
|
39
67
|
const base = new URL(baseUrl);
|
|
40
|
-
|
|
41
|
-
let fullPath;
|
|
42
|
-
if (p.startsWith('/')) {
|
|
43
|
-
// Strip a trailing slash from base.pathname, then concatenate.
|
|
44
|
-
const basePath = base.pathname.endsWith('/') ? base.pathname.slice(0, -1) : base.pathname;
|
|
45
|
-
fullPath = basePath + p;
|
|
46
|
-
} else {
|
|
47
|
-
fullPath = p;
|
|
48
|
-
}
|
|
68
|
+
const transport = base.protocol === 'http:' ? http : https;
|
|
49
69
|
const data = JSON.stringify(body);
|
|
50
|
-
const req =
|
|
51
|
-
host: base.
|
|
52
|
-
port: base.port || 443,
|
|
53
|
-
path:
|
|
70
|
+
const req = transport.request({
|
|
71
|
+
host: base.hostname,
|
|
72
|
+
port: base.port || (base.protocol === 'http:' ? 80 : 443),
|
|
73
|
+
path: buildRequestPath(base, p),
|
|
54
74
|
method: 'POST',
|
|
55
75
|
headers: {
|
|
56
76
|
'Content-Type': 'application/json',
|
|
@@ -75,52 +95,79 @@ function postJson({ baseUrl, path: p, body, apiKey, version, timeoutMs = 60_000
|
|
|
75
95
|
req.on('timeout', () => req.destroy(new Error(`timeout after ${timeoutMs}ms`)));
|
|
76
96
|
req.write(data);
|
|
77
97
|
req.end();
|
|
78
|
-
} catch (e) {
|
|
98
|
+
} catch (e) {
|
|
99
|
+
reject(new Error(`bad baseUrl: ${baseUrl}: ${e.message}`));
|
|
100
|
+
}
|
|
79
101
|
});
|
|
80
102
|
}
|
|
81
103
|
|
|
82
|
-
async function
|
|
83
|
-
const cfg = readConfig();
|
|
84
|
-
if (!cfg.apiKey)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// orchestrator's validateOutput can check.
|
|
89
|
-
const schemaHint = schema ? `\n\nThe output MUST be a JSON object matching this schema:\n${schema}\n` : '';
|
|
90
|
-
const finalUser = `${user}${schemaHint}\n\nRespond with ONLY the JSON object, no prose, no markdown fences.`;
|
|
91
|
-
const messages = [{ role: 'user', content: finalUser }];
|
|
104
|
+
async function completeText({ system, user, maxTokens = 512, temperature = 0.2, model, profileId }) {
|
|
105
|
+
const cfg = readConfig({ profileId });
|
|
106
|
+
if (!cfg.apiKey) {
|
|
107
|
+
const id = profileId ? ` for profile ${profileId}` : '';
|
|
108
|
+
throw new Error(`API key not set${id}`);
|
|
109
|
+
}
|
|
92
110
|
const body = {
|
|
93
|
-
model:
|
|
94
|
-
max_tokens: maxTokens,
|
|
95
|
-
temperature,
|
|
96
|
-
messages,
|
|
111
|
+
model: model || cfg.model,
|
|
112
|
+
max_tokens: maxTokens || cfg.maxTokens || 512,
|
|
113
|
+
temperature: temperature != null ? temperature : (cfg.temperature != null ? cfg.temperature : 0.2),
|
|
114
|
+
messages: [{ role: 'user', content: user }],
|
|
97
115
|
};
|
|
98
116
|
if (system) body.system = system;
|
|
117
|
+
|
|
99
118
|
const raw = await postJson({
|
|
100
119
|
baseUrl: cfg.baseUrl,
|
|
101
120
|
path: '/v1/messages',
|
|
102
121
|
body,
|
|
103
122
|
apiKey: cfg.apiKey,
|
|
104
123
|
version: cfg.version,
|
|
124
|
+
timeoutMs: cfg.timeoutMs,
|
|
105
125
|
});
|
|
106
|
-
|
|
126
|
+
|
|
107
127
|
const text = (raw.content || [])
|
|
108
|
-
.filter(
|
|
109
|
-
.map(
|
|
128
|
+
.filter(block => block && block.type === 'text' && typeof block.text === 'string')
|
|
129
|
+
.map(block => block.text)
|
|
110
130
|
.join('\n')
|
|
111
131
|
.trim();
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
text,
|
|
135
|
+
raw,
|
|
136
|
+
usage: raw.usage || null,
|
|
137
|
+
model: raw.model || body.model,
|
|
138
|
+
profileId: cfg.profileId,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function completeJson({ system, user, schema, maxTokens = 2048, temperature = 0.2, model, profileId }) {
|
|
143
|
+
const schemaHint = schema ? `\n\nThe output MUST be a JSON object matching this schema:\n${schema}\n` : '';
|
|
144
|
+
const finalUser = `${user}${schemaHint}\n\nRespond with ONLY the JSON object, no prose, no markdown fences.`;
|
|
145
|
+
const result = await completeText({ system, user: finalUser, maxTokens, temperature, model, profileId });
|
|
146
|
+
const text = result.text;
|
|
147
|
+
|
|
112
148
|
let parsed = null;
|
|
113
149
|
let parseError = null;
|
|
114
|
-
// First try direct parse; if that fails, strip a leading ```json fence.
|
|
115
150
|
const candidates = [
|
|
116
151
|
text,
|
|
117
152
|
text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, ''),
|
|
118
153
|
];
|
|
119
|
-
for (const
|
|
120
|
-
if (!
|
|
121
|
-
try {
|
|
154
|
+
for (const candidate of candidates) {
|
|
155
|
+
if (!candidate) continue;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(candidate);
|
|
158
|
+
parseError = null;
|
|
159
|
+
break;
|
|
160
|
+
} catch (e) {
|
|
161
|
+
parseError = e;
|
|
162
|
+
}
|
|
122
163
|
}
|
|
123
|
-
|
|
164
|
+
|
|
165
|
+
return { ...result, parsed, parseError };
|
|
124
166
|
}
|
|
125
167
|
|
|
126
|
-
module.exports = {
|
|
168
|
+
module.exports = {
|
|
169
|
+
completeJson,
|
|
170
|
+
completeText,
|
|
171
|
+
readConfig,
|
|
172
|
+
findProfile,
|
|
173
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Prompt Registry — reads/writes claude-prompts.json and renders templated prompts.
|
|
2
|
+
//
|
|
3
|
+
// File location: <KB_ROOT>/claude-prompts.json
|
|
4
|
+
// Schema: { schema: 'claude-prompts/v1', prompts: { <key>: { description, model, permissionMode,
|
|
5
|
+
// allowedTools, systemPrompt, userPrompt } } }
|
|
6
|
+
//
|
|
7
|
+
// Variable interpolation: {{KEY}} placeholders in systemPrompt/userPrompt are replaced
|
|
8
|
+
// with values from the `vars` argument. Unknown placeholders are left untouched (so
|
|
9
|
+
// incomplete prompts don't crash — they show the raw token for diagnosis).
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const KB_ROOT = path.resolve(__dirname, '..', '..');
|
|
15
|
+
const PROMPTS_PATH = path.join(KB_ROOT, 'claude-prompts.json');
|
|
16
|
+
const SCHEMA = 'claude-prompts/v1';
|
|
17
|
+
|
|
18
|
+
const PLACEHOLDER_RE = /\{\{\s*([A-Z_][A-Z0-9_]*)\s*\}\}/g;
|
|
19
|
+
|
|
20
|
+
function readPrompts() {
|
|
21
|
+
if (!fs.existsSync(PROMPTS_PATH)) {
|
|
22
|
+
return { schema: SCHEMA, prompts: {} };
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const cfg = JSON.parse(fs.readFileSync(PROMPTS_PATH, 'utf-8'));
|
|
26
|
+
if (!cfg || typeof cfg !== 'object') return { schema: SCHEMA, prompts: {} };
|
|
27
|
+
if (!cfg.prompts || typeof cfg.prompts !== 'object') cfg.prompts = {};
|
|
28
|
+
if (!cfg.schema) cfg.schema = SCHEMA;
|
|
29
|
+
return cfg;
|
|
30
|
+
} catch (e) {
|
|
31
|
+
throw new Error(`failed to parse ${PROMPTS_PATH}: ${e.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writePrompts(cfg) {
|
|
36
|
+
if (!cfg || typeof cfg !== 'object') throw new Error('prompts config must be an object');
|
|
37
|
+
if (cfg.prompts && typeof cfg.prompts !== 'object') throw new Error('prompts field must be an object');
|
|
38
|
+
const normalized = {
|
|
39
|
+
schema: cfg.schema || SCHEMA,
|
|
40
|
+
prompts: cfg.prompts || {},
|
|
41
|
+
};
|
|
42
|
+
for (const [key, val] of Object.entries(normalized.prompts)) {
|
|
43
|
+
if (!key || typeof key !== 'string') throw new Error(`invalid prompt key: ${key}`);
|
|
44
|
+
if (!val || typeof val !== 'object') throw new Error(`prompt "${key}" must be an object`);
|
|
45
|
+
if (typeof val.userPrompt !== 'string' || !val.userPrompt) {
|
|
46
|
+
throw new Error(`prompt "${key}" must have a non-empty userPrompt`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
fs.writeFileSync(PROMPTS_PATH, JSON.stringify(normalized, null, 2) + '\n', 'utf-8');
|
|
50
|
+
return normalized;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function listPromptKeys() {
|
|
54
|
+
return Object.keys(readPrompts().prompts);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getPrompt(key) {
|
|
58
|
+
const cfg = readPrompts();
|
|
59
|
+
const p = cfg.prompts[key];
|
|
60
|
+
if (!p) return null;
|
|
61
|
+
return p;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function interpolate(text, vars) {
|
|
65
|
+
if (typeof text !== 'string') return text;
|
|
66
|
+
if (!vars || typeof vars !== 'object') return text;
|
|
67
|
+
return text.replace(PLACEHOLDER_RE, (_, name) => {
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(vars, name)) {
|
|
69
|
+
const v = vars[name];
|
|
70
|
+
return v == null ? '' : String(v);
|
|
71
|
+
}
|
|
72
|
+
return `{{${name}}}`;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Render the prompt for a given key, returning all fields needed to spawn claude.
|
|
77
|
+
// Returns null if key doesn't exist.
|
|
78
|
+
function renderPrompt(key, vars) {
|
|
79
|
+
const p = getPrompt(key);
|
|
80
|
+
if (!p) return null;
|
|
81
|
+
const safeVars = vars || {};
|
|
82
|
+
return {
|
|
83
|
+
key,
|
|
84
|
+
description: p.description || '',
|
|
85
|
+
model: p.model || 'sonnet',
|
|
86
|
+
permissionMode: p.permissionMode || 'bypassPermissions',
|
|
87
|
+
allowedTools: Array.isArray(p.allowedTools) ? p.allowedTools.slice() : ['Read', 'Grep', 'Glob'],
|
|
88
|
+
systemPrompt: interpolate(p.systemPrompt || '', safeVars),
|
|
89
|
+
userPrompt: interpolate(p.userPrompt, safeVars),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
PROMPTS_PATH,
|
|
95
|
+
SCHEMA,
|
|
96
|
+
readPrompts,
|
|
97
|
+
writePrompts,
|
|
98
|
+
listPromptKeys,
|
|
99
|
+
getPrompt,
|
|
100
|
+
renderPrompt,
|
|
101
|
+
interpolate,
|
|
102
|
+
};
|