@wangzhizhi/remi 0.0.1-alpha
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 +9 -0
- package/dist/doctor.js +108 -0
- package/dist/git.js +41 -0
- package/dist/help.js +27 -0
- package/dist/i18n.js +422 -0
- package/dist/index.js +97 -0
- package/dist/initPrompt.js +17 -0
- package/dist/model.js +116 -0
- package/dist/modelSelection.js +34 -0
- package/dist/permissionDisplay.js +46 -0
- package/dist/permissions.js +206 -0
- package/dist/repl.js +346 -0
- package/dist/resume.js +3 -0
- package/dist/setup.js +62 -0
- package/dist/statusline.js +59 -0
- package/dist/style.js +48 -0
- package/dist/syntaxTheme.js +39 -0
- package/dist/tui/RemiApp.js +1756 -0
- package/dist/tui/commands.js +427 -0
- package/dist/tui/index.js +42 -0
- package/dist/tui/renderers/Header.js +28 -0
- package/dist/tui/renderers/MessageList.js +1176 -0
- package/dist/tui/renderers/PromptBox.js +118 -0
- package/dist/tui/renderers/StatusLine.js +124 -0
- package/dist/tui/renderers/WorkingIndicator.js +70 -0
- package/dist/tui/slashCommandHighlight.js +8 -0
- package/dist/tui/theme.js +13 -0
- package/dist/tui/types.js +1 -0
- package/dist/usage.js +66 -0
- package/dist/version.js +5 -0
- package/node_modules/@remi/compact/dist/index.js +389 -0
- package/node_modules/@remi/compact/package.json +8 -0
- package/node_modules/@remi/config/dist/index.js +426 -0
- package/node_modules/@remi/config/package.json +8 -0
- package/node_modules/@remi/core/dist/contextBuilder.js +344 -0
- package/node_modules/@remi/core/dist/directoryOverview.js +359 -0
- package/node_modules/@remi/core/dist/index.js +2843 -0
- package/node_modules/@remi/core/dist/projectInstructions.js +123 -0
- package/node_modules/@remi/core/dist/responseStyles.js +98 -0
- package/node_modules/@remi/core/package.json +8 -0
- package/node_modules/@remi/llm/dist/index.js +804 -0
- package/node_modules/@remi/llm/package.json +8 -0
- package/node_modules/@remi/memory/dist/index.js +312 -0
- package/node_modules/@remi/memory/package.json +8 -0
- package/node_modules/@remi/permissions/dist/index.js +90 -0
- package/node_modules/@remi/permissions/package.json +8 -0
- package/node_modules/@remi/sessions/dist/index.js +370 -0
- package/node_modules/@remi/sessions/package.json +8 -0
- package/node_modules/@remi/skills/dist/index.js +273 -0
- package/node_modules/@remi/skills/package.json +8 -0
- package/node_modules/@remi/terminal-markdown/dist/index.js +1412 -0
- package/node_modules/@remi/terminal-markdown/package.json +8 -0
- package/node_modules/@remi/tools/dist/index.js +3875 -0
- package/node_modules/@remi/tools/package.json +8 -0
- package/package.json +48 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
export const sessionsPackageName = '@remi/sessions';
|
|
5
|
+
export function createSessionId() {
|
|
6
|
+
return randomUUID();
|
|
7
|
+
}
|
|
8
|
+
export function sessionsDir(cwd = process.cwd()) {
|
|
9
|
+
return join(cwd, '.agent', 'sessions');
|
|
10
|
+
}
|
|
11
|
+
export function artifactsDir(cwd = process.cwd()) {
|
|
12
|
+
return join(cwd, '.agent', 'artifacts');
|
|
13
|
+
}
|
|
14
|
+
export function toolResultArtifactsDir(cwd, sessionId) {
|
|
15
|
+
return join(artifactsDir(cwd), 'tool-results', safeArtifactSegment(sessionId));
|
|
16
|
+
}
|
|
17
|
+
export function sessionPath(cwd, sessionId) {
|
|
18
|
+
return join(sessionsDir(cwd), `${sessionId}.jsonl`);
|
|
19
|
+
}
|
|
20
|
+
export function sessionSummaryPath(cwd, sessionId) {
|
|
21
|
+
return join(sessionsDir(cwd), `${safeArtifactSegment(sessionId)}.summary.md`);
|
|
22
|
+
}
|
|
23
|
+
export function sessionPermissionRulesPath(cwd, sessionId) {
|
|
24
|
+
return join(sessionsDir(cwd), `${sessionId}.permissions.json`);
|
|
25
|
+
}
|
|
26
|
+
export function sessionIndexPath(cwd = process.cwd()) {
|
|
27
|
+
return join(cwd, '.agent', 'sessions', 'index.json');
|
|
28
|
+
}
|
|
29
|
+
export function activeSessionPath(cwd = process.cwd()) {
|
|
30
|
+
return join(cwd, '.agent', 'session.json');
|
|
31
|
+
}
|
|
32
|
+
export function projectInputHistoryPath(cwd = process.cwd()) {
|
|
33
|
+
return join(cwd, '.agent', 'input-history.json');
|
|
34
|
+
}
|
|
35
|
+
export function sessionExists(cwd, sessionId) {
|
|
36
|
+
return existsSync(sessionPath(cwd, sessionId));
|
|
37
|
+
}
|
|
38
|
+
export function readActiveSessionId(cwd = process.cwd()) {
|
|
39
|
+
const path = activeSessionPath(cwd);
|
|
40
|
+
if (!existsSync(path)) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
45
|
+
if (typeof parsed.sessionId !== 'string' || !sessionExists(cwd, parsed.sessionId)) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
return parsed.sessionId;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function writeActiveSessionId(cwd, sessionId) {
|
|
55
|
+
ensureAgentDir(cwd);
|
|
56
|
+
writeFileSync(activeSessionPath(cwd), `${JSON.stringify({ sessionId, updatedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
57
|
+
}
|
|
58
|
+
export function writeToolResultArtifact(cwd, sessionId, input) {
|
|
59
|
+
const createdAt = input.now ?? new Date().toISOString();
|
|
60
|
+
const contentType = input.contentType ?? 'application/json';
|
|
61
|
+
const id = `${safeArtifactSegment(input.toolName)}-${safeArtifactSegment(input.callId)}`;
|
|
62
|
+
const directory = toolResultArtifactsDir(cwd, sessionId);
|
|
63
|
+
const path = join(directory, `${id}.json`);
|
|
64
|
+
const bytes = Buffer.byteLength(input.content, 'utf8');
|
|
65
|
+
const record = {
|
|
66
|
+
type: 'tool_result',
|
|
67
|
+
sessionId,
|
|
68
|
+
callId: input.callId,
|
|
69
|
+
toolName: input.toolName,
|
|
70
|
+
createdAt,
|
|
71
|
+
contentType,
|
|
72
|
+
bytes,
|
|
73
|
+
content: input.content,
|
|
74
|
+
};
|
|
75
|
+
mkdirSync(directory, { recursive: true });
|
|
76
|
+
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
77
|
+
return {
|
|
78
|
+
id,
|
|
79
|
+
kind: 'tool-result',
|
|
80
|
+
path: relative(cwd, path),
|
|
81
|
+
bytes,
|
|
82
|
+
contentType,
|
|
83
|
+
createdAt,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function createNewSession(cwd = process.cwd()) {
|
|
87
|
+
const sessionId = createSessionId();
|
|
88
|
+
ensureSessionDirs(cwd);
|
|
89
|
+
writeFileSync(sessionPath(cwd, sessionId), '');
|
|
90
|
+
writeActiveSessionId(cwd, sessionId);
|
|
91
|
+
upsertSessionIndex(cwd, sessionId);
|
|
92
|
+
return sessionId;
|
|
93
|
+
}
|
|
94
|
+
export function resolveActiveSessionId(cwd = process.cwd()) {
|
|
95
|
+
return readActiveSessionId(cwd) ?? createNewSession(cwd);
|
|
96
|
+
}
|
|
97
|
+
export function readSessionEvents(cwd, sessionId) {
|
|
98
|
+
const path = sessionPath(cwd, sessionId);
|
|
99
|
+
if (!existsSync(path)) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
return readFileSync(path, 'utf8')
|
|
103
|
+
.split('\n')
|
|
104
|
+
.map(line => line.trim())
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.flatMap(line => {
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(line);
|
|
109
|
+
return isSessionEvent(parsed) ? [parsed] : [];
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
export function readSessionUserInputs(cwd, sessionId) {
|
|
117
|
+
return readSessionEvents(cwd, sessionId)
|
|
118
|
+
.filter((event) => event.type === 'user')
|
|
119
|
+
.map(event => event.content);
|
|
120
|
+
}
|
|
121
|
+
export function readProjectInputHistory(cwd = process.cwd(), limit = 200) {
|
|
122
|
+
return readProjectInputHistoryEntries(cwd)
|
|
123
|
+
.slice(-Math.max(0, limit))
|
|
124
|
+
.map(entry => entry.content);
|
|
125
|
+
}
|
|
126
|
+
export function appendProjectInputHistory(cwd, content, sessionId) {
|
|
127
|
+
const normalized = content.trim();
|
|
128
|
+
if (normalized.length === 0) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const current = readProjectInputHistoryEntries(cwd);
|
|
132
|
+
const last = current[current.length - 1];
|
|
133
|
+
if (last?.content === normalized) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const timestamp = new Date().toISOString();
|
|
137
|
+
const nextEntries = [
|
|
138
|
+
...current,
|
|
139
|
+
{
|
|
140
|
+
content: normalized,
|
|
141
|
+
timestamp,
|
|
142
|
+
...(sessionId ? { sessionId } : {}),
|
|
143
|
+
},
|
|
144
|
+
].slice(-200);
|
|
145
|
+
writeProjectInputHistory(cwd, {
|
|
146
|
+
updatedAt: timestamp,
|
|
147
|
+
entries: nextEntries,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
export function readSessionPermissionRules(cwd, sessionId) {
|
|
151
|
+
const path = sessionPermissionRulesPath(cwd, sessionId);
|
|
152
|
+
if (!existsSync(path)) {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
157
|
+
if (!isSessionPermissionRulesFile(parsed) || parsed.sessionId !== sessionId) {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
return parsed.rules;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export function writeSessionPermissionRules(cwd, sessionId, rules) {
|
|
167
|
+
ensureSessionDirs(cwd);
|
|
168
|
+
const validRules = rules.filter(isSessionPermissionAllowRule);
|
|
169
|
+
const payload = {
|
|
170
|
+
sessionId,
|
|
171
|
+
updatedAt: new Date().toISOString(),
|
|
172
|
+
rules: validRules,
|
|
173
|
+
};
|
|
174
|
+
writeFileSync(sessionPermissionRulesPath(cwd, sessionId), `${JSON.stringify(payload, null, 2)}\n`);
|
|
175
|
+
}
|
|
176
|
+
export function createSessionStore(cwd = process.cwd(), sessionId = createSessionId()) {
|
|
177
|
+
ensureSessionDirs(cwd);
|
|
178
|
+
const path = sessionPath(cwd, sessionId);
|
|
179
|
+
if (!existsSync(path)) {
|
|
180
|
+
writeFileSync(path, '');
|
|
181
|
+
}
|
|
182
|
+
writeActiveSessionId(cwd, sessionId);
|
|
183
|
+
upsertSessionIndex(cwd, sessionId);
|
|
184
|
+
return {
|
|
185
|
+
sessionId,
|
|
186
|
+
sessionPath: path,
|
|
187
|
+
append(event) {
|
|
188
|
+
const timestamp = new Date().toISOString();
|
|
189
|
+
const record = {
|
|
190
|
+
...event,
|
|
191
|
+
sessionId,
|
|
192
|
+
timestamp,
|
|
193
|
+
};
|
|
194
|
+
appendFileSync(path, `${JSON.stringify(record)}\n`);
|
|
195
|
+
upsertSessionIndex(cwd, sessionId, record);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function ensureAgentDir(cwd) {
|
|
200
|
+
mkdirSync(join(cwd, '.agent'), { recursive: true });
|
|
201
|
+
}
|
|
202
|
+
function ensureSessionDirs(cwd) {
|
|
203
|
+
ensureAgentDir(cwd);
|
|
204
|
+
mkdirSync(sessionsDir(cwd), { recursive: true });
|
|
205
|
+
}
|
|
206
|
+
function safeArtifactSegment(value) {
|
|
207
|
+
const safe = value.replace(/[^A-Za-z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
208
|
+
return (safe.length > 0 ? safe : 'artifact').slice(0, 120);
|
|
209
|
+
}
|
|
210
|
+
function readProjectInputHistoryEntries(cwd) {
|
|
211
|
+
const path = projectInputHistoryPath(cwd);
|
|
212
|
+
if (!existsSync(path)) {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
217
|
+
if (isProjectInputHistoryFile(parsed)) {
|
|
218
|
+
return parsed.entries;
|
|
219
|
+
}
|
|
220
|
+
if (Array.isArray(parsed)) {
|
|
221
|
+
return parsed
|
|
222
|
+
.filter((entry) => typeof entry === 'string')
|
|
223
|
+
.map(content => ({ content, timestamp: new Date(0).toISOString() }));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
function writeProjectInputHistory(cwd, history) {
|
|
232
|
+
ensureAgentDir(cwd);
|
|
233
|
+
writeFileSync(projectInputHistoryPath(cwd), `${JSON.stringify(history, null, 2)}\n`);
|
|
234
|
+
}
|
|
235
|
+
function readSessionIndex(cwd) {
|
|
236
|
+
const path = sessionIndexPath(cwd);
|
|
237
|
+
if (!existsSync(path)) {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
242
|
+
return Array.isArray(parsed) ? parsed.filter(isSessionIndexEntry) : [];
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function writeSessionIndex(cwd, entries) {
|
|
249
|
+
ensureSessionDirs(cwd);
|
|
250
|
+
writeFileSync(sessionIndexPath(cwd), `${JSON.stringify(entries, null, 2)}\n`);
|
|
251
|
+
}
|
|
252
|
+
function upsertSessionIndex(cwd, sessionId, event) {
|
|
253
|
+
const path = sessionPath(cwd, sessionId);
|
|
254
|
+
const now = event?.timestamp ?? new Date().toISOString();
|
|
255
|
+
const entries = readSessionIndex(cwd);
|
|
256
|
+
const existing = entries.find(entry => entry.sessionId === sessionId);
|
|
257
|
+
const title = existing?.title ?? (event?.type === 'user' ? titleFromInput(event.content) : undefined);
|
|
258
|
+
const nextEntry = {
|
|
259
|
+
sessionId,
|
|
260
|
+
sessionPath: path,
|
|
261
|
+
cwd,
|
|
262
|
+
createdAt: existing?.createdAt ?? now,
|
|
263
|
+
updatedAt: now,
|
|
264
|
+
...(event ? { lastMessageAt: now } : existing?.lastMessageAt ? { lastMessageAt: existing.lastMessageAt } : {}),
|
|
265
|
+
...(title ? { title } : {}),
|
|
266
|
+
};
|
|
267
|
+
const nextEntries = [nextEntry, ...entries.filter(entry => entry.sessionId !== sessionId)];
|
|
268
|
+
writeSessionIndex(cwd, nextEntries);
|
|
269
|
+
}
|
|
270
|
+
function titleFromInput(input) {
|
|
271
|
+
const title = input.replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
272
|
+
return title.length > 0 ? title : undefined;
|
|
273
|
+
}
|
|
274
|
+
function isSessionIndexEntry(value) {
|
|
275
|
+
if (!value || typeof value !== 'object') {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
const entry = value;
|
|
279
|
+
return typeof entry.sessionId === 'string' && typeof entry.sessionPath === 'string' && typeof entry.cwd === 'string';
|
|
280
|
+
}
|
|
281
|
+
function isSessionPermissionRulesFile(value) {
|
|
282
|
+
if (!value || typeof value !== 'object') {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
const file = value;
|
|
286
|
+
return typeof file.sessionId === 'string' && typeof file.updatedAt === 'string' && Array.isArray(file.rules) && file.rules.every(isSessionPermissionAllowRule);
|
|
287
|
+
}
|
|
288
|
+
function isProjectInputHistoryFile(value) {
|
|
289
|
+
if (!value || typeof value !== 'object') {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
const file = value;
|
|
293
|
+
return typeof file.updatedAt === 'string' && Array.isArray(file.entries) && file.entries.every(isProjectInputHistoryEntry);
|
|
294
|
+
}
|
|
295
|
+
function isProjectInputHistoryEntry(value) {
|
|
296
|
+
if (!value || typeof value !== 'object') {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
const entry = value;
|
|
300
|
+
return typeof entry.content === 'string' && typeof entry.timestamp === 'string' && optionalString(entry.sessionId);
|
|
301
|
+
}
|
|
302
|
+
function isSessionPermissionAllowRule(value) {
|
|
303
|
+
if (!value || typeof value !== 'object') {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
const rule = value;
|
|
307
|
+
if (rule.kind === 'shell-prefix') {
|
|
308
|
+
return typeof rule.prefix === 'string' && optionalString(rule.cwd) && optionalString(rule.createdAt);
|
|
309
|
+
}
|
|
310
|
+
if (rule.kind === 'filesystem-write-root') {
|
|
311
|
+
return (typeof rule.root === 'string' &&
|
|
312
|
+
optionalString(rule.createdAt) &&
|
|
313
|
+
(rule.operations === undefined || (Array.isArray(rule.operations) && rule.operations.every(isSessionPermissionFilesystemOperation))));
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
function isSessionPermissionFilesystemOperation(value) {
|
|
318
|
+
return value === 'create' || value === 'write' || value === 'edit' || value === 'delete';
|
|
319
|
+
}
|
|
320
|
+
function optionalString(value) {
|
|
321
|
+
return value === undefined || typeof value === 'string';
|
|
322
|
+
}
|
|
323
|
+
function isSessionEvent(value) {
|
|
324
|
+
if (!value || typeof value !== 'object') {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
const event = value;
|
|
328
|
+
if (typeof event.sessionId !== 'string' || typeof event.timestamp !== 'string') {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
if (event.type === 'user') {
|
|
332
|
+
return typeof event.content === 'string';
|
|
333
|
+
}
|
|
334
|
+
if (event.type === 'system') {
|
|
335
|
+
return typeof event.content === 'string' && (event.level === 'info' || event.level === 'warn' || event.level === 'error');
|
|
336
|
+
}
|
|
337
|
+
if (event.type === 'assistant') {
|
|
338
|
+
return typeof event.content === 'string' && Boolean(event.model && typeof event.model === 'object');
|
|
339
|
+
}
|
|
340
|
+
if (event.type === 'tool_call') {
|
|
341
|
+
return typeof event.callId === 'string' && typeof event.toolName === 'string' && Boolean(event.input && typeof event.input === 'object');
|
|
342
|
+
}
|
|
343
|
+
if (event.type === 'tool_result') {
|
|
344
|
+
return (typeof event.callId === 'string' &&
|
|
345
|
+
typeof event.toolName === 'string' &&
|
|
346
|
+
typeof event.ok === 'boolean' &&
|
|
347
|
+
typeof event.summary === 'string' &&
|
|
348
|
+
(event.artifact === undefined || isSessionArtifactRef(event.artifact)));
|
|
349
|
+
}
|
|
350
|
+
if (event.type === 'compact') {
|
|
351
|
+
return (typeof event.summary === 'string' &&
|
|
352
|
+
typeof event.estimatedTokens === 'number' &&
|
|
353
|
+
typeof event.sourceEventCount === 'number' &&
|
|
354
|
+
(event.trigger === undefined || event.trigger === 'manual' || event.trigger === 'auto' || event.trigger === 'reactive') &&
|
|
355
|
+
(event.strategy === undefined || event.strategy === 'deterministic' || event.strategy === 'model'));
|
|
356
|
+
}
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
function isSessionArtifactRef(value) {
|
|
360
|
+
if (!value || typeof value !== 'object') {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
const artifact = value;
|
|
364
|
+
return (typeof artifact.id === 'string' &&
|
|
365
|
+
artifact.kind === 'tool-result' &&
|
|
366
|
+
typeof artifact.path === 'string' &&
|
|
367
|
+
typeof artifact.bytes === 'number' &&
|
|
368
|
+
(artifact.contentType === 'application/json' || artifact.contentType === 'text/plain') &&
|
|
369
|
+
typeof artifact.createdAt === 'string');
|
|
370
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
export const skillsPackageName = '@remi/skills';
|
|
5
|
+
const defaultMaxDescriptionChars = 240;
|
|
6
|
+
const defaultMaxSkillContentChars = 24_000;
|
|
7
|
+
export function globalSkillsDir(homeDir = homedir()) {
|
|
8
|
+
return join(homeDir, '.remi', 'skills');
|
|
9
|
+
}
|
|
10
|
+
export function projectSkillsDirs(cwd = process.cwd(), homeDir = homedir()) {
|
|
11
|
+
const start = resolve(cwd);
|
|
12
|
+
const home = resolve(homeDir);
|
|
13
|
+
const dirs = [];
|
|
14
|
+
let current = start;
|
|
15
|
+
while (true) {
|
|
16
|
+
dirs.push(join(current, 'skills'));
|
|
17
|
+
if (current === home || current === dirname(current)) {
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
current = dirname(current);
|
|
21
|
+
}
|
|
22
|
+
return dirs.reverse();
|
|
23
|
+
}
|
|
24
|
+
export function skillDiscoveryRoots(options = {}) {
|
|
25
|
+
const cwd = options.cwd ?? process.cwd();
|
|
26
|
+
const homeDir = options.homeDir ?? homedir();
|
|
27
|
+
const roots = [
|
|
28
|
+
{ source: 'global', path: globalSkillsDir(homeDir), exists: false },
|
|
29
|
+
...projectSkillsDirs(cwd, homeDir).map(path => ({ source: 'project', path, exists: false })),
|
|
30
|
+
];
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
return roots
|
|
33
|
+
.map(root => ({ ...root, path: resolve(root.path), exists: existsSync(root.path) }))
|
|
34
|
+
.filter(root => {
|
|
35
|
+
const key = safeRealpath(root.path) ?? root.path;
|
|
36
|
+
if (seen.has(key)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
seen.add(key);
|
|
40
|
+
return true;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function loadSkillIndex(options = {}) {
|
|
44
|
+
const disabled = new Set((options.disabled ?? []).map(normalizeSkillName).filter(Boolean));
|
|
45
|
+
const roots = skillDiscoveryRoots(options);
|
|
46
|
+
const byName = new Map();
|
|
47
|
+
for (const root of roots) {
|
|
48
|
+
if (!root.exists) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
for (const entry of loadSkillsFromRoot(root, disabled, options.maxDescriptionChars ?? defaultMaxDescriptionChars)) {
|
|
52
|
+
byName.set(normalizeSkillName(entry.name), entry);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
roots,
|
|
57
|
+
skills: Array.from(byName.values()).sort(compareSkills),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function loadSkillContent(options) {
|
|
61
|
+
const index = loadSkillIndex(options);
|
|
62
|
+
const target = normalizeSkillName(options.name);
|
|
63
|
+
const skill = index.skills.find(candidate => normalizeSkillName(candidate.name) === target);
|
|
64
|
+
if (!skill || !skill.enabled) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
const parsed = parseSkillFile(readFileSync(skill.filePath, 'utf8'));
|
|
68
|
+
return {
|
|
69
|
+
...skill,
|
|
70
|
+
content: truncateText(parsed.body.trim(), options.maxContentChars ?? defaultMaxSkillContentChars),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function formatSkillIndexContext(index, loadedSkills = []) {
|
|
74
|
+
const enabledSkills = index.skills.filter(skill => skill.enabled);
|
|
75
|
+
if (enabledSkills.length === 0 && loadedSkills.length === 0) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const lines = [
|
|
79
|
+
'## Remi Skills',
|
|
80
|
+
'',
|
|
81
|
+
'Progressive disclosure: this section lists discovered skills from `skills/<name>/SKILL.md` and `~/.remi/skills/<name>/SKILL.md`. Only the skill index is loaded by default; full skill instructions appear below only when the user explicitly selects a skill or the model loads it with the `load_skill` tool.',
|
|
82
|
+
'When a listed skill semantically matches the user request, call `load_skill` with the skill name before reading files, editing files, running commands, or answering about the task. If the match is ambiguous, do not load a skill. Do not claim that a skill was used unless full instructions have been loaded.',
|
|
83
|
+
];
|
|
84
|
+
if (enabledSkills.length > 0) {
|
|
85
|
+
lines.push('', 'Available skill index:');
|
|
86
|
+
for (const skill of enabledSkills) {
|
|
87
|
+
const when = skill.whenToUse ? ` Use when: ${skill.whenToUse}` : '';
|
|
88
|
+
const aliases = skill.aliases?.length ? ` Aliases: ${skill.aliases.join(', ')}.` : '';
|
|
89
|
+
const keywords = skill.keywords?.length ? ` Keywords: ${skill.keywords.join(', ')}.` : '';
|
|
90
|
+
lines.push(`- ${skill.name} (${skill.source}): ${skill.description} Path: ${skill.filePath}.${aliases}${keywords}${when}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (loadedSkills.length > 0) {
|
|
94
|
+
lines.push('', 'Loaded skill instructions:');
|
|
95
|
+
for (const skill of loadedSkills) {
|
|
96
|
+
lines.push('', `### ${skill.name}`, `Source: ${skill.filePath}`, skill.content);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
|
101
|
+
export function buildSkillInvocationPrompt(skill, args = []) {
|
|
102
|
+
const userArgs = args.join(' ').trim();
|
|
103
|
+
return [
|
|
104
|
+
`Use the Remi skill \`${skill.name}\` for this turn.`,
|
|
105
|
+
`Skill source: ${skill.filePath}`,
|
|
106
|
+
userArgs ? `User arguments: ${userArgs}` : 'No extra user arguments were provided.',
|
|
107
|
+
'',
|
|
108
|
+
'Skill instructions:',
|
|
109
|
+
skill.content,
|
|
110
|
+
].join('\n');
|
|
111
|
+
}
|
|
112
|
+
function loadSkillsFromRoot(root, disabled, maxDescriptionChars) {
|
|
113
|
+
let entries;
|
|
114
|
+
try {
|
|
115
|
+
entries = readdirSync(root.path, { withFileTypes: true });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
return entries
|
|
121
|
+
.filter(entry => entry.isDirectory() || entry.isSymbolicLink())
|
|
122
|
+
.map(entry => loadSkillFromDirectory(root, entry.name, disabled, maxDescriptionChars))
|
|
123
|
+
.filter((entry) => Boolean(entry));
|
|
124
|
+
}
|
|
125
|
+
function loadSkillFromDirectory(root, directoryName, disabled, maxDescriptionChars) {
|
|
126
|
+
const directory = join(root.path, directoryName);
|
|
127
|
+
const filePath = join(directory, 'SKILL.md');
|
|
128
|
+
if (!isFile(filePath)) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
const parsed = parseSkillFile(readFileSync(filePath, 'utf8'));
|
|
132
|
+
const name = normalizeSkillName(parsed.frontmatter.name || directoryName);
|
|
133
|
+
if (!name) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
const frontmatterEnabled = parseBoolean(parsed.frontmatter.enabled);
|
|
137
|
+
const displayName = parsed.frontmatter.title || parsed.frontmatter.displayName || parsed.frontmatter.display_name || name;
|
|
138
|
+
const description = truncateText(parsed.frontmatter.description || firstBodyParagraph(parsed.body) || 'No description provided.', maxDescriptionChars);
|
|
139
|
+
const aliases = uniqueStringList([
|
|
140
|
+
...parseDelimitedFrontmatterList(parsed.frontmatter.alias),
|
|
141
|
+
...parseDelimitedFrontmatterList(parsed.frontmatter.aliases),
|
|
142
|
+
]);
|
|
143
|
+
const keywords = uniqueStringList([
|
|
144
|
+
...parseDelimitedFrontmatterList(parsed.frontmatter.keyword),
|
|
145
|
+
...parseDelimitedFrontmatterList(parsed.frontmatter.keywords),
|
|
146
|
+
...parseDelimitedFrontmatterList(parsed.frontmatter.tags),
|
|
147
|
+
...parseDelimitedFrontmatterList(parsed.frontmatter.triggers),
|
|
148
|
+
]);
|
|
149
|
+
const whenToUse = parsed.frontmatter.whenToUse || parsed.frontmatter.when_to_use || parsed.frontmatter.when;
|
|
150
|
+
const enabled = frontmatterEnabled !== false && !disabled.has(name);
|
|
151
|
+
return {
|
|
152
|
+
name,
|
|
153
|
+
displayName,
|
|
154
|
+
description,
|
|
155
|
+
...(aliases.length > 0 ? { aliases } : {}),
|
|
156
|
+
...(keywords.length > 0 ? { keywords } : {}),
|
|
157
|
+
...(whenToUse ? { whenToUse: truncateText(whenToUse, maxDescriptionChars) } : {}),
|
|
158
|
+
source: root.source,
|
|
159
|
+
directory,
|
|
160
|
+
filePath,
|
|
161
|
+
enabled,
|
|
162
|
+
estimatedTokens: estimateSkillTokens([name, displayName, description, ...aliases, ...keywords, whenToUse].filter(Boolean).join(' ')),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function parseSkillFile(content) {
|
|
166
|
+
if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) {
|
|
167
|
+
return { frontmatter: {}, body: content };
|
|
168
|
+
}
|
|
169
|
+
const newline = content.startsWith('---\r\n') ? '\r\n' : '\n';
|
|
170
|
+
const endMarker = `${newline}---${newline}`;
|
|
171
|
+
const end = content.indexOf(endMarker, 3);
|
|
172
|
+
if (end < 0) {
|
|
173
|
+
return { frontmatter: {}, body: content };
|
|
174
|
+
}
|
|
175
|
+
const rawFrontmatter = content.slice(3 + newline.length - 1, end).trim();
|
|
176
|
+
const body = content.slice(end + endMarker.length);
|
|
177
|
+
return {
|
|
178
|
+
frontmatter: parseSimpleFrontmatter(rawFrontmatter),
|
|
179
|
+
body,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function parseSimpleFrontmatter(raw) {
|
|
183
|
+
const result = {};
|
|
184
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
185
|
+
const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line.trim());
|
|
186
|
+
if (!match?.[1]) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
result[normalizeFrontmatterKey(match[1])] = stripQuotes(match[2] ?? '');
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
function normalizeFrontmatterKey(key) {
|
|
194
|
+
if (key === 'when-to-use')
|
|
195
|
+
return 'when_to_use';
|
|
196
|
+
if (key === 'display-name')
|
|
197
|
+
return 'display_name';
|
|
198
|
+
return key;
|
|
199
|
+
}
|
|
200
|
+
function stripQuotes(value) {
|
|
201
|
+
const trimmed = value.trim();
|
|
202
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
203
|
+
return trimmed.slice(1, -1);
|
|
204
|
+
}
|
|
205
|
+
return trimmed;
|
|
206
|
+
}
|
|
207
|
+
function parseDelimitedFrontmatterList(value) {
|
|
208
|
+
if (!value) {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
const trimmed = value.trim();
|
|
212
|
+
const unwrapped = trimmed.startsWith('[') && trimmed.endsWith(']') ? trimmed.slice(1, -1) : trimmed;
|
|
213
|
+
return unwrapped
|
|
214
|
+
.split(/[,|]/)
|
|
215
|
+
.map(stripQuotes)
|
|
216
|
+
.map(item => item.trim())
|
|
217
|
+
.filter(Boolean);
|
|
218
|
+
}
|
|
219
|
+
function uniqueStringList(values) {
|
|
220
|
+
return Array.from(new Set(values.map(value => value.trim()).filter(Boolean)));
|
|
221
|
+
}
|
|
222
|
+
function firstBodyParagraph(body) {
|
|
223
|
+
return body
|
|
224
|
+
.split(/\n\s*\n/)
|
|
225
|
+
.map(paragraph => paragraph.trim())
|
|
226
|
+
.find(paragraph => paragraph.length > 0 && !paragraph.startsWith('#'));
|
|
227
|
+
}
|
|
228
|
+
function parseBoolean(value) {
|
|
229
|
+
if (value === undefined) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
if (/^(true|yes|1)$/i.test(value)) {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
if (/^(false|no|0)$/i.test(value)) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
function compareSkills(a, b) {
|
|
241
|
+
if (a.source !== b.source) {
|
|
242
|
+
return a.source === 'project' ? -1 : 1;
|
|
243
|
+
}
|
|
244
|
+
return a.name.localeCompare(b.name);
|
|
245
|
+
}
|
|
246
|
+
function normalizeSkillName(value) {
|
|
247
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
248
|
+
}
|
|
249
|
+
function truncateText(value, maxChars) {
|
|
250
|
+
if (value.length <= maxChars) {
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
return `${value.slice(0, Math.max(0, maxChars - 14)).trimEnd()} [truncated]`;
|
|
254
|
+
}
|
|
255
|
+
function estimateSkillTokens(value) {
|
|
256
|
+
return Math.max(1, Math.ceil(value.length / 4));
|
|
257
|
+
}
|
|
258
|
+
function isFile(path) {
|
|
259
|
+
try {
|
|
260
|
+
return statSync(path).isFile();
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function safeRealpath(path) {
|
|
267
|
+
try {
|
|
268
|
+
return realpathSync(path);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
}
|