codeply-cli 0.2.0 → 0.3.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/THIRD_PARTY_NOTICES.md +63 -0
- package/bin/codeply.js +558 -370
- package/lib/agent.mjs +1627 -160
- package/lib/ai.js +1107 -387
- package/lib/apply-limit.js +33 -51
- package/lib/apply-patch.js +217 -0
- package/lib/arity-table.json +138 -0
- package/lib/arity.js +70 -0
- package/lib/auth.js +118 -112
- package/lib/commands.js +103 -0
- package/lib/config.js +392 -136
- package/lib/design-library/artifact-template.html +500 -0
- package/lib/design-library/assemble-artifact.mjs +21 -0
- package/lib/design-library/build.mjs +226 -0
- package/lib/design-library/cleanup.mjs +45 -0
- package/lib/design-library/index.json +22380 -0
- package/lib/design-library/make-table-data.mjs +39 -0
- package/lib/design-library/query.mjs +104 -0
- package/lib/diagnostics.js +192 -0
- package/lib/edit-engine.js +171 -14
- package/lib/github-agent.mjs +307 -0
- package/lib/history.js +75 -0
- package/lib/mcp.js +258 -0
- package/lib/native-tools.mjs +189 -0
- package/lib/oauth-connectors.js +478 -0
- package/lib/permissions.js +109 -0
- package/lib/plugins.js +353 -0
- package/lib/render-md.mjs +617 -617
- package/lib/server.mjs +457 -0
- package/lib/session-db.js +220 -0
- package/lib/share.js +168 -0
- package/lib/skills.js +80 -22
- package/lib/snapshot.js +146 -0
- package/lib/subagents.js +164 -0
- package/lib/terminal-hints.js +154 -0
- package/lib/textarea.mjs +258 -258
- package/lib/tools.mjs +1658 -79
- package/lib/tui.js +7 -7
- package/lib/tui.mjs +1086 -992
- package/lib/typecheck-worker.js +147 -0
- package/lib/web-tools.js +158 -0
- package/package.json +4 -3
- package/skills/premium-web-design/SKILL.md +154 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite-backed session store. Replaces rewriting one big JSON file on every
|
|
3
|
+
* save: each session and each message is a row, and a save only writes the rows
|
|
4
|
+
* whose content changed. Messages are searchable across every session.
|
|
5
|
+
*
|
|
6
|
+
* Driver order (first that loads wins), all with the same sync API:
|
|
7
|
+
* 1. node:sqlite (Node 22.5+, built in, nothing to install)
|
|
8
|
+
* 2. better-sqlite3 (optional dependency; Electron 29 needs this one)
|
|
9
|
+
* When neither loads, openSessionDb() returns null and the caller keeps its
|
|
10
|
+
* JSON file, so a machine without SQLite never loses anything.
|
|
11
|
+
*
|
|
12
|
+
* Shape it stores (same as the old JSON store):
|
|
13
|
+
* { sessions: [{ id, title, cwd, messages: [...], alwaysAllowed, createdAt, updatedAt, ...extra }],
|
|
14
|
+
* <any other top-level key>: value }
|
|
15
|
+
*/
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const SCHEMA_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
function loadDriver(modulePaths) {
|
|
22
|
+
try {
|
|
23
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
24
|
+
if (DatabaseSync) return { kind: 'node:sqlite', open: (f) => new DatabaseSync(f) };
|
|
25
|
+
} catch {}
|
|
26
|
+
try {
|
|
27
|
+
let mod;
|
|
28
|
+
try { mod = require('better-sqlite3'); }
|
|
29
|
+
catch (e) {
|
|
30
|
+
if (!modulePaths || !modulePaths.length) throw e;
|
|
31
|
+
mod = require(require.resolve('better-sqlite3', { paths: modulePaths }));
|
|
32
|
+
}
|
|
33
|
+
return { kind: 'better-sqlite3', open: (f) => new mod(f) };
|
|
34
|
+
} catch {}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CORE_SESSION_KEYS = new Set(['id', 'title', 'cwd', 'messages', 'alwaysAllowed', 'createdAt', 'updatedAt']);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} file database path (created if missing)
|
|
42
|
+
* @param {{ modulePaths?: string[], driver?: 'node:sqlite'|'better-sqlite3' }} [opts]
|
|
43
|
+
* @returns {null | { kind:string, file:string, isEmpty():boolean, load():object, save(store:object):void, search(q:string, limit?:number):object[], close():void }}
|
|
44
|
+
*/
|
|
45
|
+
function openSessionDb(file, opts = {}) {
|
|
46
|
+
const driver = loadDriver(opts.modulePaths);
|
|
47
|
+
if (!driver || (opts.driver && driver.kind !== opts.driver)) return null;
|
|
48
|
+
let db;
|
|
49
|
+
try {
|
|
50
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
51
|
+
db = driver.open(file);
|
|
52
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
53
|
+
db.exec('PRAGMA synchronous = NORMAL');
|
|
54
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
55
|
+
db.exec(`
|
|
56
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
57
|
+
CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
58
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
59
|
+
id TEXT PRIMARY KEY,
|
|
60
|
+
title TEXT,
|
|
61
|
+
cwd TEXT,
|
|
62
|
+
always_allowed TEXT,
|
|
63
|
+
extra TEXT,
|
|
64
|
+
created_at INTEGER,
|
|
65
|
+
updated_at INTEGER,
|
|
66
|
+
position INTEGER NOT NULL DEFAULT 0
|
|
67
|
+
);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
69
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
70
|
+
idx INTEGER NOT NULL,
|
|
71
|
+
kind TEXT,
|
|
72
|
+
body TEXT,
|
|
73
|
+
data TEXT NOT NULL,
|
|
74
|
+
PRIMARY KEY (session_id, idx)
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS sessions_updated ON sessions(updated_at DESC);
|
|
77
|
+
`);
|
|
78
|
+
db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)').run('schema', String(SCHEMA_VERSION));
|
|
79
|
+
} catch {
|
|
80
|
+
try { db && db.close(); } catch {}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const q = {
|
|
85
|
+
countSessions: db.prepare('SELECT COUNT(*) AS n FROM sessions'),
|
|
86
|
+
countKv: db.prepare('SELECT COUNT(*) AS n FROM kv'),
|
|
87
|
+
allSessions: db.prepare('SELECT * FROM sessions ORDER BY position ASC'),
|
|
88
|
+
sessionMessages: db.prepare('SELECT data FROM messages WHERE session_id = ? ORDER BY idx ASC'),
|
|
89
|
+
allKv: db.prepare('SELECT key, value FROM kv'),
|
|
90
|
+
upsertSession: db.prepare(`INSERT INTO sessions (id, title, cwd, always_allowed, extra, created_at, updated_at, position)
|
|
91
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
92
|
+
ON CONFLICT(id) DO UPDATE SET title=excluded.title, cwd=excluded.cwd, always_allowed=excluded.always_allowed,
|
|
93
|
+
extra=excluded.extra, created_at=excluded.created_at, updated_at=excluded.updated_at, position=excluded.position`),
|
|
94
|
+
deleteSession: db.prepare('DELETE FROM sessions WHERE id = ?'),
|
|
95
|
+
listSessionIds: db.prepare('SELECT id FROM sessions'),
|
|
96
|
+
upsertMessage: db.prepare(`INSERT INTO messages (session_id, idx, kind, body, data) VALUES (?, ?, ?, ?, ?)
|
|
97
|
+
ON CONFLICT(session_id, idx) DO UPDATE SET kind=excluded.kind, body=excluded.body, data=excluded.data`),
|
|
98
|
+
trimMessages: db.prepare('DELETE FROM messages WHERE session_id = ? AND idx >= ?'),
|
|
99
|
+
upsertKv: db.prepare('INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value'),
|
|
100
|
+
deleteKv: db.prepare('DELETE FROM kv WHERE key = ?'),
|
|
101
|
+
search: db.prepare(`SELECT m.session_id AS sessionId, m.idx AS idx, m.kind AS kind, m.body AS body, s.title AS title, s.cwd AS cwd, s.updated_at AS updatedAt
|
|
102
|
+
FROM messages m JOIN sessions s ON s.id = m.session_id
|
|
103
|
+
WHERE m.body LIKE ? ESCAPE '\\' ORDER BY s.updated_at DESC, m.idx ASC LIMIT ?`),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// What the database already holds, so a save only writes what changed.
|
|
107
|
+
const seenSession = new Map(); // id -> serialized session header
|
|
108
|
+
const seenMessages = new Map(); // id -> string[] (serialized messages)
|
|
109
|
+
const seenKv = new Map(); // key -> serialized value
|
|
110
|
+
|
|
111
|
+
const tx = (fn) => {
|
|
112
|
+
db.exec('BEGIN');
|
|
113
|
+
try { fn(); db.exec('COMMIT'); } catch (e) { try { db.exec('ROLLBACK'); } catch {} throw e; }
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const bodyOf = (m) => {
|
|
117
|
+
if (!m || typeof m !== 'object') return '';
|
|
118
|
+
const t = m.text != null ? m.text : (m.label != null ? m.label : '');
|
|
119
|
+
return typeof t === 'string' ? t.slice(0, 20000) : '';
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
kind: driver.kind,
|
|
124
|
+
file,
|
|
125
|
+
|
|
126
|
+
isEmpty() {
|
|
127
|
+
return q.countSessions.get().n === 0 && q.countKv.get().n === 0;
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
load() {
|
|
131
|
+
const store = {};
|
|
132
|
+
for (const row of q.allKv.all()) {
|
|
133
|
+
try { store[row.key] = JSON.parse(row.value); seenKv.set(row.key, row.value); } catch {}
|
|
134
|
+
}
|
|
135
|
+
store.sessions = q.allSessions.all().map((r, position) => {
|
|
136
|
+
const messages = q.sessionMessages.all(r.id).map((m) => { try { return JSON.parse(m.data); } catch { return null; } }).filter(Boolean);
|
|
137
|
+
let extra = {};
|
|
138
|
+
try { extra = r.extra ? JSON.parse(r.extra) : {}; } catch {}
|
|
139
|
+
let alwaysAllowed = [];
|
|
140
|
+
try { alwaysAllowed = r.always_allowed ? JSON.parse(r.always_allowed) : []; } catch {}
|
|
141
|
+
const s = { ...extra, id: r.id, title: r.title, cwd: r.cwd, messages, alwaysAllowed, createdAt: r.created_at, updatedAt: r.updated_at };
|
|
142
|
+
seenSession.set(r.id, headerOf(s, position));
|
|
143
|
+
seenMessages.set(r.id, messages.map((m) => JSON.stringify(m)));
|
|
144
|
+
return s;
|
|
145
|
+
});
|
|
146
|
+
return store;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
save(store) {
|
|
150
|
+
const sessions = Array.isArray(store.sessions) ? store.sessions : [];
|
|
151
|
+
tx(() => {
|
|
152
|
+
const keep = new Set();
|
|
153
|
+
sessions.forEach((s, position) => {
|
|
154
|
+
if (!s || !s.id) return;
|
|
155
|
+
keep.add(s.id);
|
|
156
|
+
const header = headerOf(s, position);
|
|
157
|
+
if (seenSession.get(s.id) !== header) {
|
|
158
|
+
const extra = {};
|
|
159
|
+
for (const k of Object.keys(s)) if (!CORE_SESSION_KEYS.has(k)) extra[k] = s[k];
|
|
160
|
+
q.upsertSession.run(
|
|
161
|
+
s.id, s.title == null ? null : String(s.title), s.cwd == null ? null : String(s.cwd),
|
|
162
|
+
JSON.stringify(s.alwaysAllowed || []), JSON.stringify(extra),
|
|
163
|
+
Number(s.createdAt) || Date.now(), Number(s.updatedAt) || Date.now(), position,
|
|
164
|
+
);
|
|
165
|
+
seenSession.set(s.id, header);
|
|
166
|
+
}
|
|
167
|
+
const msgs = Array.isArray(s.messages) ? s.messages : [];
|
|
168
|
+
const prev = seenMessages.get(s.id) || [];
|
|
169
|
+
const next = new Array(msgs.length);
|
|
170
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
171
|
+
const ser = JSON.stringify(msgs[i]);
|
|
172
|
+
next[i] = ser;
|
|
173
|
+
if (prev[i] !== ser) q.upsertMessage.run(s.id, i, msgs[i] && msgs[i].kind ? String(msgs[i].kind) : null, bodyOf(msgs[i]), ser);
|
|
174
|
+
}
|
|
175
|
+
if (prev.length > msgs.length) q.trimMessages.run(s.id, msgs.length);
|
|
176
|
+
seenMessages.set(s.id, next);
|
|
177
|
+
});
|
|
178
|
+
for (const { id } of q.listSessionIds.all()) {
|
|
179
|
+
if (!keep.has(id)) { q.deleteSession.run(id); seenSession.delete(id); seenMessages.delete(id); }
|
|
180
|
+
}
|
|
181
|
+
const keys = new Set();
|
|
182
|
+
for (const k of Object.keys(store)) {
|
|
183
|
+
if (k === 'sessions') continue;
|
|
184
|
+
keys.add(k);
|
|
185
|
+
if (store[k] === undefined) continue;
|
|
186
|
+
const ser = JSON.stringify(store[k]);
|
|
187
|
+
if (seenKv.get(k) !== ser) { q.upsertKv.run(k, ser); seenKv.set(k, ser); }
|
|
188
|
+
}
|
|
189
|
+
for (const k of [...seenKv.keys()]) if (!keys.has(k)) { q.deleteKv.run(k); seenKv.delete(k); }
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
search(query, limit = 50) {
|
|
194
|
+
const term = String(query || '').trim();
|
|
195
|
+
if (!term) return [];
|
|
196
|
+
const like = `%${term.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
197
|
+
return q.search.all(like, Math.min(Math.max(limit | 0, 1), 200)).map((r) => ({
|
|
198
|
+
...r, snippet: snippetAround(r.body, term),
|
|
199
|
+
}));
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
close() { try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch {} try { db.close(); } catch {} },
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function headerOf(s, position) {
|
|
207
|
+
const extra = {};
|
|
208
|
+
for (const k of Object.keys(s)) if (!CORE_SESSION_KEYS.has(k)) extra[k] = s[k];
|
|
209
|
+
return JSON.stringify([s.title, s.cwd, s.alwaysAllowed || [], extra, Number(s.createdAt) || 0, Number(s.updatedAt) || 0, position]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function snippetAround(text, term) {
|
|
213
|
+
const t = String(text || '');
|
|
214
|
+
const at = t.toLowerCase().indexOf(term.toLowerCase());
|
|
215
|
+
if (at < 0) return t.slice(0, 120);
|
|
216
|
+
const from = Math.max(0, at - 50);
|
|
217
|
+
return `${from > 0 ? '...' : ''}${t.slice(from, at + term.length + 70).replace(/\s+/g, ' ')}${at + term.length + 70 < t.length ? '...' : ''}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { openSessionDb };
|
package/lib/share.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a chat into something you can hand to someone else: a Markdown file, a
|
|
3
|
+
* self-contained HTML page, or a secret GitHub gist. Everything is cleaned
|
|
4
|
+
* first: keys and tokens are masked and your home folder and project path are
|
|
5
|
+
* replaced, so a shared chat does not leak the machine it came from.
|
|
6
|
+
*/
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const SECRET_PATTERNS = [
|
|
11
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
12
|
+
/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g,
|
|
13
|
+
/\bsk-ant-[A-Za-z0-9_-]{16,}/g,
|
|
14
|
+
/\bgh[pousr]_[A-Za-z0-9]{30,}/g,
|
|
15
|
+
/\bgithub_pat_[A-Za-z0-9_]{30,}/g,
|
|
16
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
|
|
17
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
18
|
+
/\bAIza[0-9A-Za-z_-]{30,}/g,
|
|
19
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
|
|
20
|
+
/(\bBearer\s+)[A-Za-z0-9._~+/=-]{20,}/gi,
|
|
21
|
+
/((?:api[_-]?key|secret|token|password|passwd)["']?\s*[:=]\s*["']?)[^\s"',;]{8,}/gi,
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
25
|
+
|
|
26
|
+
/** Cleans one string for sharing. `cwd` is the project folder, if known. */
|
|
27
|
+
function scrub(text, cwd) {
|
|
28
|
+
let s = String(text == null ? '' : text);
|
|
29
|
+
for (const re of SECRET_PATTERNS) s = s.replace(re, (m, pre) => (typeof pre === 'string' ? `${pre}[hidden]` : '[hidden]'));
|
|
30
|
+
const paths = [];
|
|
31
|
+
if (cwd) paths.push([cwd, '<project>']);
|
|
32
|
+
const home = os.homedir();
|
|
33
|
+
if (home && home.length > 3) paths.push([home, '~']);
|
|
34
|
+
for (const [from, to] of paths) {
|
|
35
|
+
for (const v of new Set([from, from.replace(/\\/g, '/')])) s = s.replace(new RegExp(escapeRe(v), 'gi'), to);
|
|
36
|
+
}
|
|
37
|
+
return s;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The parts of a stored chat that belong in a shared copy. */
|
|
41
|
+
function collect(session, opts = {}) {
|
|
42
|
+
const cwd = session.cwd || '';
|
|
43
|
+
const clean = (t) => scrub(t, cwd);
|
|
44
|
+
const items = [];
|
|
45
|
+
for (const m of session.messages || []) {
|
|
46
|
+
if (m.kind === 'user') items.push({ type: 'user', text: clean(m.text), images: (m.images || []).length });
|
|
47
|
+
else if (m.kind === 'assistant' && m.text && !m.interim) items.push({ type: 'assistant', text: clean(m.text) });
|
|
48
|
+
else if (m.kind === 'assistant' && m.text && opts.includeNarration) items.push({ type: 'narration', text: clean(m.text) });
|
|
49
|
+
else if (m.kind === 'reasoning' && m.text && opts.includeThinking) items.push({ type: 'thinking', text: clean(m.text) });
|
|
50
|
+
else if (m.kind === 'tool') items.push({ type: 'tool', name: m.name, label: clean(m.label || ''), ok: m.ok !== false });
|
|
51
|
+
else if (m.kind === 'question') items.push({ type: 'question', question: clean(m.question), answer: m.answer ? clean(m.answer) : '' });
|
|
52
|
+
else if (m.kind === 'notice' && m.level === 'error') items.push({ type: 'error', text: clean(m.text) });
|
|
53
|
+
}
|
|
54
|
+
return { title: clean(session.title || 'Chat'), project: cwd ? path.basename(cwd) : '', when: session.updatedAt || session.createdAt || null, items };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toMarkdown(session, opts) {
|
|
58
|
+
const c = collect(session, opts);
|
|
59
|
+
const out = [`# ${c.title}`, ''];
|
|
60
|
+
const meta = [c.project && `Project: ${c.project}`, c.when && new Date(c.when).toISOString().slice(0, 10), 'Made with Codeply Craft'].filter(Boolean);
|
|
61
|
+
out.push(`_${meta.join(' - ')}_`, '');
|
|
62
|
+
let tools = [];
|
|
63
|
+
const flush = () => { if (tools.length) { out.push(...tools, ''); tools = []; } };
|
|
64
|
+
for (const it of c.items) {
|
|
65
|
+
if (it.type === 'tool') { tools.push(`- \`${it.name}\` ${it.label}${it.ok ? '' : ' (failed)'}`.trimEnd()); continue; }
|
|
66
|
+
flush();
|
|
67
|
+
if (it.type === 'user') out.push('## You', '', it.text + (it.images ? `\n\n_(${it.images} image${it.images === 1 ? '' : 's'} not included)_` : ''), '');
|
|
68
|
+
else if (it.type === 'assistant') out.push('## Codeply', '', it.text, '');
|
|
69
|
+
else if (it.type === 'narration') out.push(`> ${it.text.replace(/\n/g, '\n> ')}`, '');
|
|
70
|
+
else if (it.type === 'thinking') out.push('<details><summary>Thinking</summary>', '', it.text, '', '</details>', '');
|
|
71
|
+
else if (it.type === 'question') out.push(`> Asked: ${it.question}${it.answer ? ` - answered: ${it.answer}` : ''}`, '');
|
|
72
|
+
else if (it.type === 'error') out.push(`> Error: ${it.text}`, '');
|
|
73
|
+
}
|
|
74
|
+
flush();
|
|
75
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
79
|
+
|
|
80
|
+
/** A small, safe Markdown subset: everything is escaped first, then fences, code, bold, links, lists. */
|
|
81
|
+
function inline(escaped) {
|
|
82
|
+
return escaped
|
|
83
|
+
.replace(/`([^`\n]+)`/g, '<code>$1</code>')
|
|
84
|
+
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
|
|
85
|
+
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" rel="noopener noreferrer nofollow">$1</a>');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function mdBlock(text) {
|
|
89
|
+
const parts = String(text).split(/```/);
|
|
90
|
+
let html = '';
|
|
91
|
+
parts.forEach((part, i) => {
|
|
92
|
+
if (i % 2 === 1) {
|
|
93
|
+
const nl = part.indexOf('\n');
|
|
94
|
+
const lang = nl === -1 ? '' : part.slice(0, nl).trim();
|
|
95
|
+
const code = nl === -1 ? part : part.slice(nl + 1);
|
|
96
|
+
html += `<pre${lang ? ` data-lang="${esc(lang.slice(0, 20))}"` : ''}><code>${esc(code.replace(/\n$/, ''))}</code></pre>`;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
let list = null;
|
|
100
|
+
const closeList = () => { if (list) { html += `</${list}>`; list = null; } };
|
|
101
|
+
for (const line of part.split('\n')) {
|
|
102
|
+
const e = esc(line);
|
|
103
|
+
let m;
|
|
104
|
+
if ((m = /^\s*[-*]\s+(.*)$/.exec(e))) { if (list !== 'ul') { closeList(); html += '<ul>'; list = 'ul'; } html += `<li>${inline(m[1])}</li>`; }
|
|
105
|
+
else if ((m = /^\s*\d+[.)]\s+(.*)$/.exec(e))) { if (list !== 'ol') { closeList(); html += '<ol>'; list = 'ol'; } html += `<li>${inline(m[1])}</li>`; }
|
|
106
|
+
else if ((m = /^#{1,6}\s+(.*)$/.exec(e))) { closeList(); html += `<p><strong>${inline(m[1])}</strong></p>`; }
|
|
107
|
+
else if (e.trim()) { closeList(); html += `<p>${inline(e)}</p>`; }
|
|
108
|
+
else closeList();
|
|
109
|
+
}
|
|
110
|
+
closeList();
|
|
111
|
+
});
|
|
112
|
+
return html;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const CSS = `:root{color-scheme:light dark;--bg:#fff;--fg:#1d1d1f;--mut:#6e6e73;--card:#f5f5f7;--line:#e5e5ea;--acc:#5b5bd6}
|
|
116
|
+
@media(prefers-color-scheme:dark){:root{--bg:#141416;--fg:#ececf0;--mut:#9a9aa3;--card:#1f1f23;--line:#2c2c31;--acc:#8b8bf0}}
|
|
117
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 system-ui,-apple-system,Segoe UI,sans-serif}
|
|
118
|
+
main{max-width:760px;margin:0 auto;padding:32px 20px 64px}h1{font-size:22px;margin:0 0 4px}.meta{color:var(--mut);font-size:13px;margin-bottom:28px}
|
|
119
|
+
.turn{margin:18px 0}.who{font-size:12px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--mut);margin-bottom:4px}
|
|
120
|
+
.user .body{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px 14px}.assistant .who{color:var(--acc)}
|
|
121
|
+
.body p{margin:.5em 0}.body ul,.body ol{margin:.5em 0;padding-left:1.4em}code{font:13px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;background:var(--card);border-radius:4px;padding:1px 5px}
|
|
122
|
+
pre{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px;overflow:auto;margin:.7em 0}pre code{background:none;padding:0}
|
|
123
|
+
.tools{margin:6px 0;padding:0;list-style:none;color:var(--mut);font-size:13px}.tools li{padding:1px 0}.tools .bad{color:#d1453b}.note{color:var(--mut);font-size:14px;border-left:3px solid var(--line);padding-left:10px;margin:10px 0}
|
|
124
|
+
.err{color:#d1453b}footer{margin-top:40px;color:var(--mut);font-size:12px}a{color:var(--acc)}details{color:var(--mut);font-size:13px}`;
|
|
125
|
+
|
|
126
|
+
function toHtml(session, opts) {
|
|
127
|
+
const c = collect(session, opts);
|
|
128
|
+
const meta = [c.project && esc(c.project), c.when && new Date(c.when).toISOString().slice(0, 10)].filter(Boolean).join(' · ');
|
|
129
|
+
let body = '';
|
|
130
|
+
let tools = [];
|
|
131
|
+
const flush = () => { if (tools.length) { body += `<ul class="tools">${tools.join('')}</ul>`; tools = []; } };
|
|
132
|
+
for (const it of c.items) {
|
|
133
|
+
if (it.type === 'tool') { tools.push(`<li${it.ok ? '' : ' class="bad"'}><code>${esc(it.name)}</code> ${esc(it.label)}${it.ok ? '' : ' (failed)'}</li>`); continue; }
|
|
134
|
+
flush();
|
|
135
|
+
if (it.type === 'user') body += `<div class="turn user"><div class="who">You</div><div class="body">${mdBlock(it.text)}${it.images ? `<p class="note">${it.images} image${it.images === 1 ? '' : 's'} not included</p>` : ''}</div></div>`;
|
|
136
|
+
else if (it.type === 'assistant') body += `<div class="turn assistant"><div class="who">Codeply</div><div class="body">${mdBlock(it.text)}</div></div>`;
|
|
137
|
+
else if (it.type === 'narration') body += `<div class="note">${mdBlock(it.text)}</div>`;
|
|
138
|
+
else if (it.type === 'thinking') body += `<details><summary>Thinking</summary>${mdBlock(it.text)}</details>`;
|
|
139
|
+
else if (it.type === 'question') body += `<div class="note">Asked: ${esc(it.question)}${it.answer ? ` · answered: ${esc(it.answer)}` : ''}</div>`;
|
|
140
|
+
else if (it.type === 'error') body += `<div class="note err">${esc(it.text)}</div>`;
|
|
141
|
+
}
|
|
142
|
+
flush();
|
|
143
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>${esc(c.title)}</title><style>${CSS}</style></head><body><main><h1>${esc(c.title)}</h1><div class="meta">${meta}</div>${body}<footer>Made with Codeply Craft</footer></main></body></html>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Uploads the chat as a secret gist. Returns {ok, url} or {ok:false, error}. */
|
|
147
|
+
async function createGist(session, token, { fetchImpl = fetch, apiUrl = 'https://api.github.com', isPublic = false, ...opts } = {}) {
|
|
148
|
+
if (!token) return { ok: false, error: 'No GitHub token.' };
|
|
149
|
+
const c = collect(session, opts);
|
|
150
|
+
const base = (c.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)) || 'chat';
|
|
151
|
+
let res;
|
|
152
|
+
try {
|
|
153
|
+
res = await fetchImpl(`${apiUrl.replace(/\/$/, '')}/gists`, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'Content-Type': 'application/json', 'User-Agent': 'codeply-craft' },
|
|
156
|
+
body: JSON.stringify({ description: `${c.title} (Codeply Craft)`.slice(0, 200), public: !!isPublic, files: { [`${base}.md`]: { content: toMarkdown(session, opts) } } }),
|
|
157
|
+
});
|
|
158
|
+
} catch (e) { return { ok: false, error: `Could not reach GitHub: ${e.message}` }; }
|
|
159
|
+
let json = null;
|
|
160
|
+
try { json = JSON.parse(await res.text()); } catch {}
|
|
161
|
+
if (!res.ok) {
|
|
162
|
+
const scopeHint = res.status === 404 || res.status === 403 ? ' Reconnect GitHub in Connect Apps so Craft can create gists.' : '';
|
|
163
|
+
return { ok: false, error: `GitHub said ${res.status}${json && json.message ? `: ${json.message}` : ''}.${scopeHint}` };
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, url: json.html_url, public: !!isPublic };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { scrub, collect, toMarkdown, toHtml, createGist };
|
package/lib/skills.js
CHANGED
|
@@ -3,42 +3,44 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A skill is a directory with a SKILL.md: YAML frontmatter (`name`,
|
|
5
5
|
* `description`) followed by a markdown body of instructions. This is the same
|
|
6
|
-
* convention Claude Code and affaan-m/ECC use
|
|
6
|
+
* convention Claude Code and affaan-m/ECC use - see skills/SOURCE.md for where
|
|
7
7
|
* the bundled set came from and why the format was copied rather than invented.
|
|
8
8
|
*
|
|
9
|
-
* Two-level progressive disclosure
|
|
9
|
+
* Two-level progressive disclosure - this matters at 281 skills:
|
|
10
10
|
*
|
|
11
11
|
* 1. Per-skill: buildSystemPrompt() only ever injects a skill's NAME and a
|
|
12
|
-
* capped one-line description. The full body
|
|
13
|
-
* KB with reference files
|
|
12
|
+
* capped one-line description. The full body - which can run to several
|
|
13
|
+
* KB with reference files - loads on demand via <codeply:use_skill>. The
|
|
14
14
|
* alternative, concatenating every skill's full body into the system
|
|
15
15
|
* prompt always, would resend ~3.4MB per request for the bundled set
|
|
16
16
|
* alone.
|
|
17
17
|
*
|
|
18
18
|
* 2. Across the library: even name+description for all 281 is ~44KB
|
|
19
|
-
* (~11,000 tokens)
|
|
19
|
+
* (~11,000 tokens) - resent on every step of every turn, that alone would
|
|
20
20
|
* make the skill library cost more than it saves. So the system prompt by
|
|
21
21
|
* default only lists a curated subset (DAILY_SKILLS below, ~2KB) covering
|
|
22
22
|
* general coding work; the complete 281 are still fully installed and
|
|
23
23
|
* usable, just one cheap `list_skills` action away instead of force-fed
|
|
24
|
-
* into every request. DAILY_SKILLS is not a cut Codeply invented
|
|
24
|
+
* into every request. DAILY_SKILLS is not a cut Codeply invented - it's
|
|
25
25
|
* ECC's own `.agents/skills/` curation of its 281-skill `skills/`
|
|
26
26
|
* library (see skills/SOURCE.md), reused rather than re-decided.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* Three sources, later wins on a name collision:
|
|
29
|
+
* plugin <plugin>/skills/<name>/SKILL.md - `codeply plugin install`
|
|
30
|
+
* built-in <repo>/skills/<name>/SKILL.md - vendored, ships with Codeply
|
|
31
|
+
* user ~/.codeply/skills/<name>/SKILL.md - `codeply skill install`
|
|
31
32
|
*/
|
|
32
33
|
const fs = require('fs');
|
|
33
34
|
const path = require('path');
|
|
34
35
|
const os = require('os');
|
|
36
|
+
const plugins = require('./plugins.js');
|
|
35
37
|
|
|
36
38
|
const BUILTIN_DIR = path.join(__dirname, '..', 'skills');
|
|
37
39
|
const USER_DIR = path.join(os.homedir(), '.codeply', 'skills');
|
|
38
40
|
|
|
39
41
|
const MAX_BODY_CHARS = 8000; // fed to the model on demand; keep one skill from eating the whole budget
|
|
40
42
|
|
|
41
|
-
// ECC's own curated subset of its full library
|
|
43
|
+
// ECC's own curated subset of its full library - see skills/SOURCE.md. Kept as
|
|
42
44
|
// a name list rather than a directory so a user-installed skill with one of
|
|
43
45
|
// these names (or a future re-vendor that drops/renames one) degrades
|
|
44
46
|
// gracefully instead of erroring.
|
|
@@ -53,6 +55,9 @@ const DAILY_SKILLS = new Set([
|
|
|
53
55
|
'mle-workflow', 'nextjs-turbopack', 'plan-canvas', 'product-capability',
|
|
54
56
|
'security-review', 'strategic-compact', 'tdd-workflow', 'unified-memory',
|
|
55
57
|
'verification-loop', 'video-editing', 'x-api',
|
|
58
|
+
// Codeply's own visual-quality bar - see the note above use_skill in
|
|
59
|
+
// lib/agent.mjs's TOOL_REFERENCE for why these are non-speculative triggers.
|
|
60
|
+
'premium-web-design', 'frontend-design-direction', 'motion-ui', 'frontend-a11y',
|
|
56
61
|
]);
|
|
57
62
|
|
|
58
63
|
function listSkillDirs(root, source) {
|
|
@@ -69,7 +74,7 @@ function listSkillDirs(root, source) {
|
|
|
69
74
|
/**
|
|
70
75
|
* Minimal YAML frontmatter reader for exactly the two keys skills use.
|
|
71
76
|
* Handles plain `key: value` and folded/literal block scalars (`key: >-` /
|
|
72
|
-
* `key: |-` etc.) since real skills in the wild use both
|
|
77
|
+
* `key: |-` etc.) since real skills in the wild use both - a naive
|
|
73
78
|
* single-line regex silently truncates ~10% of the bundled set's descriptions.
|
|
74
79
|
*/
|
|
75
80
|
function parseFrontmatter(text) {
|
|
@@ -106,7 +111,7 @@ function parseFrontmatter(text) {
|
|
|
106
111
|
continue;
|
|
107
112
|
}
|
|
108
113
|
|
|
109
|
-
// Strip a matching pair of quotes, nothing fancier
|
|
114
|
+
// Strip a matching pair of quotes, nothing fancier - these are simple labels.
|
|
110
115
|
values[key] = rest.replace(/^["'](.*)["']$/, '$1').trim();
|
|
111
116
|
}
|
|
112
117
|
|
|
@@ -118,9 +123,13 @@ function parseFrontmatter(text) {
|
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
/** Every skill available right now, built-in and user, deduped by name. */
|
|
121
|
-
function listSkills() {
|
|
126
|
+
function listSkills(cwd) {
|
|
122
127
|
const found = new Map();
|
|
123
128
|
for (const s of listSkillDirs(BUILTIN_DIR, 'built-in')) found.set(s.name, s);
|
|
129
|
+
// Plugin skills were installed on purpose, so they are shown like the daily set.
|
|
130
|
+
for (const { plugin, dir } of plugins.skillDirs(cwd)) {
|
|
131
|
+
for (const s of listSkillDirs(dir, 'plugin')) found.set(s.name, { ...s, plugin });
|
|
132
|
+
}
|
|
124
133
|
// User skills are installed deliberately, so a same-named one wins.
|
|
125
134
|
for (const s of listSkillDirs(USER_DIR, 'user')) found.set(s.name, s);
|
|
126
135
|
|
|
@@ -136,9 +145,10 @@ function listSkills() {
|
|
|
136
145
|
description: description || '(no description)',
|
|
137
146
|
source: s.source,
|
|
138
147
|
dir: s.dir,
|
|
139
|
-
// A user-installed skill overriding a daily one keeps its daily status
|
|
148
|
+
// A user-installed skill overriding a daily one keeps its daily status -
|
|
140
149
|
// the name is still what buildSystemPrompt filters on, not the source.
|
|
141
|
-
daily: DAILY_SKILLS.has(finalName),
|
|
150
|
+
daily: DAILY_SKILLS.has(finalName) || s.source === 'plugin',
|
|
151
|
+
plugin: s.plugin || null,
|
|
142
152
|
});
|
|
143
153
|
}
|
|
144
154
|
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -149,7 +159,7 @@ const INDEX_DESC_MAX = 140; // per-skill cap in the always-injected index; use_s
|
|
|
149
159
|
/**
|
|
150
160
|
* Compact index of skill name + a capped description, one per line.
|
|
151
161
|
*
|
|
152
|
-
* `daily` (default) is what goes in the system prompt on every request
|
|
162
|
+
* `daily` (default) is what goes in the system prompt on every request - see
|
|
153
163
|
* the module doc for why the full 281 is too large to inject unconditionally.
|
|
154
164
|
* `all` is for the list_skills action and `/skill list`, where the cost is
|
|
155
165
|
* paid once, on demand, instead of on every step.
|
|
@@ -168,8 +178,8 @@ function formatSkillIndex(skills = listSkills(), { all = false } = {}) {
|
|
|
168
178
|
}
|
|
169
179
|
|
|
170
180
|
/** The full body of one skill, truncated to a sane size, or null if unknown. */
|
|
171
|
-
function loadSkillBody(name) {
|
|
172
|
-
const skill = listSkills().find((s) => s.name === name);
|
|
181
|
+
function loadSkillBody(name, cwd) {
|
|
182
|
+
const skill = listSkills(cwd).find((s) => s.name === name);
|
|
173
183
|
if (!skill) return null;
|
|
174
184
|
|
|
175
185
|
let raw;
|
|
@@ -186,7 +196,7 @@ function loadSkillBody(name) {
|
|
|
186
196
|
/**
|
|
187
197
|
* Copy a local directory containing a SKILL.md into the user skill store.
|
|
188
198
|
* Named separately from the GitHub path below because it never touches the
|
|
189
|
-
* network
|
|
199
|
+
* network - used by both `codeply skill install <local-dir>` and as the last
|
|
190
200
|
* step after a GitHub download lands in a temp directory.
|
|
191
201
|
*/
|
|
192
202
|
function installFromLocalDir(sourceDir, name) {
|
|
@@ -254,7 +264,7 @@ async function githubJson(url) {
|
|
|
254
264
|
* If `subpath` points directly at a skill (a directory whose own SKILL.md
|
|
255
265
|
* lives there), that one skill is installed under `name` (or its own frontmatter
|
|
256
266
|
* name). Otherwise every SKILL.md found anywhere under `subpath` (the whole
|
|
257
|
-
* repo, if none was given) is installed
|
|
267
|
+
* repo, if none was given) is installed - this is what makes a bare repo URL
|
|
258
268
|
* like affaan-m/ECC pull in its entire skill library in one command, and also
|
|
259
269
|
* what lets a single-skill link install just that one.
|
|
260
270
|
*/
|
|
@@ -279,7 +289,7 @@ async function installFromGitHub(source, explicitName) {
|
|
|
279
289
|
`https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`
|
|
280
290
|
);
|
|
281
291
|
if (treeData.truncated) {
|
|
282
|
-
// GitHub's own recursion cap (huge monorepos)
|
|
292
|
+
// GitHub's own recursion cap (huge monorepos) - rare, but better to say
|
|
283
293
|
// so than to silently install an incomplete skill.
|
|
284
294
|
return { ok: false, error: `${owner}/${repo}@${branch} is too large to list in one call. Point --install at a narrower subpath.` };
|
|
285
295
|
}
|
|
@@ -342,6 +352,54 @@ async function installFromGitHub(source, explicitName) {
|
|
|
342
352
|
return { ok: installed.length > 0, installed, failed, source: `${owner}/${repo}@${branch}` };
|
|
343
353
|
}
|
|
344
354
|
|
|
355
|
+
// Words too common in both task descriptions and skill blurbs to carry any
|
|
356
|
+
// matching signal ("a page for the app" would otherwise match nearly everything).
|
|
357
|
+
const STOPWORDS = new Set([
|
|
358
|
+
'a', 'an', 'the', 'and', 'or', 'but', 'for', 'with', 'to', 'of', 'in', 'on',
|
|
359
|
+
'at', 'is', 'are', 'be', 'this', 'that', 'it', 'as', 'from', 'into', 'about',
|
|
360
|
+
'i', 'you', 'we', 'my', 'me', 'do', 'does', 'make', 'makes', 'get', 'gets',
|
|
361
|
+
'up', 'out', 'not', 'need', 'want', 'please', 'can', 'app', 'page', 'file',
|
|
362
|
+
'code', 'using', 'use', 'so', 'now',
|
|
363
|
+
]);
|
|
364
|
+
|
|
365
|
+
function tokenize(text) {
|
|
366
|
+
return (text || '')
|
|
367
|
+
.toLowerCase()
|
|
368
|
+
.split(/[^a-z0-9]+/)
|
|
369
|
+
.filter((w) => w.length > 2 && !STOPWORDS.has(w));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Cheap keyword-overlap search over the full skill library, run fresh per
|
|
374
|
+
* turn against the user's actual request - no extra model call. This is what
|
|
375
|
+
* lets a niche skill (say, cisco-ios-patterns or blender-motion-state-inspection)
|
|
376
|
+
* get surfaced automatically instead of depending on the model guessing the
|
|
377
|
+
* right list_skills query, or on it being one of the ~37 always-shown daily
|
|
378
|
+
* skills. Scored on token overlap between the query and each skill's
|
|
379
|
+
* `name + description`, with name hits weighted higher than description hits
|
|
380
|
+
* since a skill named for exactly the thing being asked is strong signal.
|
|
381
|
+
*/
|
|
382
|
+
function findRelevantSkills(query, skillList = listSkills(), { limit = 6, excludeDaily = false } = {}) {
|
|
383
|
+
const qTokens = new Set(tokenize(query));
|
|
384
|
+
if (qTokens.size === 0) return [];
|
|
385
|
+
|
|
386
|
+
const scored = [];
|
|
387
|
+
for (const s of skillList) {
|
|
388
|
+
if (excludeDaily && s.daily) continue;
|
|
389
|
+
const nameTokens = tokenize(s.name.replace(/-/g, ' '));
|
|
390
|
+
const descTokens = tokenize(s.description);
|
|
391
|
+
let score = 0;
|
|
392
|
+
for (const t of qTokens) {
|
|
393
|
+
if (nameTokens.includes(t)) score += 3;
|
|
394
|
+
else if (descTokens.includes(t)) score += 1;
|
|
395
|
+
}
|
|
396
|
+
if (score > 0) scored.push({ skill: s, score });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
scored.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
|
|
400
|
+
return scored.slice(0, limit).map((x) => x.skill);
|
|
401
|
+
}
|
|
402
|
+
|
|
345
403
|
function removeSkill(name) {
|
|
346
404
|
const dest = path.join(USER_DIR, name);
|
|
347
405
|
if (!fs.existsSync(dest)) {
|
|
@@ -353,6 +411,6 @@ function removeSkill(name) {
|
|
|
353
411
|
|
|
354
412
|
module.exports = {
|
|
355
413
|
BUILTIN_DIR, USER_DIR, MAX_BODY_CHARS, DAILY_SKILLS,
|
|
356
|
-
parseFrontmatter, listSkills, formatSkillIndex, loadSkillBody,
|
|
414
|
+
parseFrontmatter, listSkills, formatSkillIndex, loadSkillBody, findRelevantSkills,
|
|
357
415
|
installFromLocalDir, installFromGitHub, parseGitHubSource, removeSkill,
|
|
358
416
|
};
|