kiosapi 0.1.27 → 0.1.29

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.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * MCP (Model Context Protocol) client — stdio transport only.
3
+ *
4
+ * Protocol: newline-delimited JSON-RPC 2.0 over child process stdin/stdout.
5
+ * No external SDK needed — the protocol is simple enough to implement directly.
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import { VERSION } from '../help.js';
9
+ // ---------------------------------------------------------------------------
10
+ // Single server connection
11
+ // ---------------------------------------------------------------------------
12
+ class McpConnection {
13
+ serverName;
14
+ proc;
15
+ buffer = '';
16
+ pending = new Map();
17
+ nextId = 1;
18
+ dead = false;
19
+ constructor(serverName, config) {
20
+ this.serverName = serverName;
21
+ const env = { ...process.env, ...(config.env ?? {}) };
22
+ // On Windows, npx/npm commands need shell:true to resolve correctly
23
+ const needsShell = process.platform === 'win32' &&
24
+ (config.command === 'npx' || config.command === 'npm' || config.command === 'node');
25
+ this.proc = spawn(config.command, config.args ?? [], {
26
+ env,
27
+ stdio: ['pipe', 'pipe', 'pipe'], // capture stderr so it doesn't pollute CLI output
28
+ shell: needsShell,
29
+ });
30
+ this.proc.stdout?.setEncoding('utf8');
31
+ this.proc.stdout?.on('data', (chunk) => {
32
+ this.buffer += chunk;
33
+ const lines = this.buffer.split('\n');
34
+ this.buffer = lines.pop() ?? '';
35
+ for (const line of lines) {
36
+ if (!line.trim())
37
+ continue;
38
+ try {
39
+ const msg = JSON.parse(line);
40
+ if (msg.id !== undefined) {
41
+ const p = this.pending.get(msg.id);
42
+ if (p) {
43
+ clearTimeout(p.timer);
44
+ this.pending.delete(msg.id);
45
+ if (msg.error) {
46
+ p.reject(new Error(msg.error.message));
47
+ }
48
+ else {
49
+ p.resolve(msg.result);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ catch {
55
+ // non-JSON line (debug output, etc.) — ignore
56
+ }
57
+ }
58
+ });
59
+ const onExit = () => {
60
+ this.dead = true;
61
+ for (const p of this.pending.values()) {
62
+ clearTimeout(p.timer);
63
+ p.reject(new Error(`MCP server "${this.serverName}" exited unexpectedly`));
64
+ }
65
+ this.pending.clear();
66
+ };
67
+ this.proc.on('exit', onExit);
68
+ this.proc.on('error', onExit);
69
+ }
70
+ write(msg) {
71
+ this.proc.stdin?.write(`${JSON.stringify(msg)}\n`);
72
+ }
73
+ request(method, params, timeoutMs = 30_000) {
74
+ return new Promise((resolve, reject) => {
75
+ if (this.dead) {
76
+ reject(new Error(`MCP server "${this.serverName}" is not running`));
77
+ return;
78
+ }
79
+ const id = this.nextId++;
80
+ const timer = setTimeout(() => {
81
+ this.pending.delete(id);
82
+ reject(new Error(`MCP "${this.serverName}" timeout after ${timeoutMs}ms`));
83
+ }, timeoutMs);
84
+ this.pending.set(id, { resolve: (r) => resolve(r), reject, timer });
85
+ this.write({ jsonrpc: '2.0', id, method, params });
86
+ });
87
+ }
88
+ async initialize() {
89
+ await this.request('initialize', {
90
+ protocolVersion: '2024-11-05',
91
+ clientInfo: { name: 'kiosapi', version: VERSION },
92
+ capabilities: { tools: {} },
93
+ }, 10_000);
94
+ // Notification — no response expected
95
+ this.write({ jsonrpc: '2.0', method: 'notifications/initialized' });
96
+ }
97
+ async listTools() {
98
+ const result = await this.request('tools/list', {}, 10_000);
99
+ return result.tools ?? [];
100
+ }
101
+ async callTool(name, args) {
102
+ const result = await this.request('tools/call', { name, arguments: args }, 60_000);
103
+ const content = result.content ?? [];
104
+ const text = content
105
+ .filter((c) => c.type === 'text' && c.text)
106
+ .map((c) => c.text ?? '')
107
+ .join('\n');
108
+ if (result.isError)
109
+ throw new Error(`MCP tool error: ${text}`);
110
+ return text || '(no output)';
111
+ }
112
+ kill() {
113
+ this.dead = true;
114
+ try {
115
+ this.proc.kill();
116
+ }
117
+ catch {
118
+ // already dead
119
+ }
120
+ }
121
+ get isAlive() {
122
+ return !this.dead;
123
+ }
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // Manager: multiple servers, unified tool registry
127
+ // ---------------------------------------------------------------------------
128
+ function sanitizeName(s) {
129
+ return s.replace(/[^a-zA-Z0-9_-]/g, '_');
130
+ }
131
+ export class McpManager {
132
+ connections = new Map();
133
+ registry = [];
134
+ /**
135
+ * Connect to all configured servers in parallel. Failures are logged but do not abort — a
136
+ * partially-connected MCP set is better than no MCP at all.
137
+ */
138
+ async connect(servers, onWarn) {
139
+ const entries = Object.entries(servers);
140
+ const results = await Promise.allSettled(entries.map(async ([name, config]) => {
141
+ const conn = new McpConnection(name, config);
142
+ await conn.initialize();
143
+ const tools = await conn.listTools();
144
+ this.connections.set(name, conn);
145
+ for (const tool of tools) {
146
+ const qualifiedName = `mcp__${sanitizeName(name)}__${sanitizeName(tool.name)}`;
147
+ this.registry.push({
148
+ qualifiedName,
149
+ serverName: name,
150
+ toolName: tool.name,
151
+ spec: {
152
+ type: 'function',
153
+ function: {
154
+ name: qualifiedName,
155
+ description: `[MCP:${name}] ${tool.description ?? tool.name}`,
156
+ parameters: tool.inputSchema,
157
+ },
158
+ },
159
+ });
160
+ }
161
+ }));
162
+ for (const [i, result] of results.entries()) {
163
+ if (result.status === 'rejected') {
164
+ const serverName = entries[i]?.[0] ?? '?';
165
+ onWarn?.(`⚠ MCP server "${serverName}" gagal: ${result.reason.message}`);
166
+ }
167
+ }
168
+ }
169
+ /** ToolSpec[] for all connected MCP tools — merged into the agent's tool list. */
170
+ getToolSpecs() {
171
+ return this.registry.map((r) => r.spec);
172
+ }
173
+ /** Dispatch a tool call by qualified name. */
174
+ async callTool(qualifiedName, args) {
175
+ const entry = this.registry.find((r) => r.qualifiedName === qualifiedName);
176
+ if (!entry)
177
+ throw new Error(`MCP tool "${qualifiedName}" tidak dikenal`);
178
+ const conn = this.connections.get(entry.serverName);
179
+ if (!conn?.isAlive)
180
+ throw new Error(`MCP server "${entry.serverName}" tidak aktif`);
181
+ return conn.callTool(entry.toolName, args);
182
+ }
183
+ get toolCount() {
184
+ return this.registry.length;
185
+ }
186
+ get serverCount() {
187
+ return this.connections.size;
188
+ }
189
+ /** Summary of servers and their tool counts — for display. */
190
+ summary() {
191
+ const byServer = new Map();
192
+ for (const r of this.registry) {
193
+ if (!byServer.has(r.serverName))
194
+ byServer.set(r.serverName, []);
195
+ byServer.get(r.serverName).push(r);
196
+ }
197
+ return [...byServer.entries()].map(([server, tools]) => ({ server, tools }));
198
+ }
199
+ disconnect() {
200
+ for (const conn of this.connections.values())
201
+ conn.kill();
202
+ this.connections.clear();
203
+ this.registry = [];
204
+ }
205
+ }
package/dist/agent/run.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { extname, join } from 'node:path';
3
3
  import { chatComplete, generateImage, resolveMediaModel, streamVision, } from '../api.js';
4
- import { CHECKPOINT_PATH } from '../config.js';
4
+ import { CHECKPOINT_PATH, loadMcpServers } from '../config.js';
5
5
  import { cyan, dim, green, idn, prompt, red, rupiah, thinking, yellow } from '../ui.js';
6
+ import { McpManager } from './mcp.js';
6
7
  import { systemPrompt, toolsForMode } from './schemas.js';
7
8
  import { bacaFile, cari, daftarFile, editFile, hapusFile, jalankan, pindahFile, safePath, tulisFile, } from './tools.js';
8
9
  const MAX_STEPS = 50;
@@ -56,32 +57,47 @@ function trimContext(messages) {
56
57
  const dropped = clean.length - tail.length;
57
58
  if (dropped <= 0)
58
59
  return [system, ...clean];
59
- // Collect baca_file paths from the dropped messages so the model knows which files it
60
- // already has context for — prevents it from re-orienting by re-reading config/schema files.
60
+ // Collect baca_file and daftar_file calls from the dropped messages so the model knows which
61
+ // files/directories it already has context for — prevents re-orientation loops after trimming.
61
62
  const droppedHead = clean.slice(0, clean.length - tail.length + skip);
62
63
  const droppedPaths = [];
64
+ const droppedDirs = [];
65
+ const droppedModified = [];
63
66
  for (const msg of droppedHead) {
64
67
  if (msg.role === 'assistant' && msg.tool_calls) {
65
68
  for (const tc of msg.tool_calls) {
66
- if (tc.function?.name === 'baca_file') {
67
- try {
68
- const a = JSON.parse(tc.function.arguments ?? '{}');
69
- if (a.path)
70
- droppedPaths.push(a.path);
71
- }
72
- catch {
73
- /* ignore */
74
- }
69
+ try {
70
+ const a = JSON.parse(tc.function.arguments ?? '{}');
71
+ if (tc.function?.name === 'baca_file' && a.path)
72
+ droppedPaths.push(a.path);
73
+ if (tc.function?.name === 'daftar_file')
74
+ droppedDirs.push(a.path || '.');
75
+ }
76
+ catch {
77
+ /* ignore */
75
78
  }
76
79
  }
77
80
  }
81
+ // Track files that were successfully written/edited/deleted in the dropped context so the
82
+ // model knows it already made those changes and shouldn't repeat them after trimming.
83
+ if (msg.role === 'tool' && msg.content) {
84
+ const m = msg.content.match(/^OK:\s+(.+?)\s+(dibuat|diubah|dihapus|dipindah)/);
85
+ if (m?.[1])
86
+ droppedModified.push(m[1]);
87
+ }
78
88
  }
79
89
  const filesNote = droppedPaths.length > 0
80
- ? ` File sudah dibaca (tak lagi di window): ${[...new Set(droppedPaths)].join(', ')}. JANGAN baca ulang — gunakan cari untuk mencari teks spesifik.`
90
+ ? ` File sudah dibaca: ${[...new Set(droppedPaths)].join(', ')}. JANGAN baca ulang — gunakan cari untuk teks spesifik.`
91
+ : '';
92
+ const dirsNote = droppedDirs.length > 0
93
+ ? ` Direktori sudah terdaftar: ${[...new Set(droppedDirs)].join(', ')}. JANGAN list ulang — gunakan baca_file untuk file spesifik.`
94
+ : '';
95
+ const modifiedNote = droppedModified.length > 0
96
+ ? ` File sudah dimodifikasi: ${[...new Set(droppedModified)].join(', ')}. JANGAN tulis ulang kecuali ada perubahan tambahan.`
81
97
  : '';
82
98
  const note = {
83
99
  role: 'system',
84
- content: `[Kiosapi: ${dropped} pesan awal dihapus dari konteks.${filesNote}]`,
100
+ content: `[Kiosapi: ${dropped} pesan awal dihapus dari konteks.${filesNote}${dirsNote}${modifiedNote}]`,
85
101
  };
86
102
  return [system, note, ...tail];
87
103
  }
@@ -472,7 +488,8 @@ export async function runTurn(s, userText) {
472
488
  }
473
489
  }
474
490
  s.messages.push({ role: 'user', content: userText });
475
- const tools = toolsForMode(s.mode);
491
+ s._lastCompleted = false;
492
+ const tools = [...toolsForMode(s.mode), ...(s.mcpManager?.getToolSpecs() ?? [])];
476
493
  let lastText = '';
477
494
  let totalIn = 0;
478
495
  let totalOut = 0;
@@ -482,12 +499,15 @@ export async function runTurn(s, userText) {
482
499
  }
483
500
  };
484
501
  // Loop detection: count total calls per signature (tool+args) across the whole runTurn.
485
- // Consecutive-3 catches obvious tight loops; count-4 catches spread-out repetition where
486
- // other calls (daftar src, baca ...) appear between the repeating call.
502
+ // Consecutive-3 catches obvious tight loops; count-N catches spread-out repetition.
503
+ // daftar_file has a lower limit (3) because re-listing the same directory is almost never
504
+ // useful; baca_file legitimately needs more chances (range reads + post-edit re-reads).
487
505
  const callCounts = new Map();
488
506
  const lastSigs = []; // last 3 sigs for consecutive detection
489
- const COUNT_LIMIT = 6; // same tool+args called 6× total → loop (higher because cache now returns content)
507
+ const COUNT_LIMITS = { daftar_file: 3, cari: 4 };
508
+ const DEFAULT_COUNT_LIMIT = 6;
490
509
  const CONSEC_LIMIT = 3; // same sig 3× in a row → loop
510
+ const countLimitFor = (toolName) => COUNT_LIMITS[toolName] ?? DEFAULT_COUNT_LIMIT;
491
511
  // Read-only tool cache: on 2nd+ identical call, return the previous result with a warning
492
512
  // instead of re-running. This gives the model early feedback so it can self-correct before
493
513
  // COUNT_LIMIT is reached, preventing the common "list root, list root, list root" pattern.
@@ -553,9 +573,9 @@ export async function runTurn(s, userText) {
553
573
  lastSigs.push(sig);
554
574
  if (lastSigs.length > CONSEC_LIMIT)
555
575
  lastSigs.shift();
556
- // Total count exceeded, or 3 in a row
576
+ // Total count exceeded (per-tool limit), or 3 in a row
557
577
  const consecutive = lastSigs.length === CONSEC_LIMIT && lastSigs.every((s) => s === sig);
558
- if (count >= COUNT_LIMIT || consecutive) {
578
+ if (count >= countLimitFor(call.function.name) || consecutive) {
559
579
  loopSig = sig;
560
580
  break;
561
581
  }
@@ -565,7 +585,14 @@ export async function runTurn(s, userText) {
565
585
  const total = callCounts.get(loopSig) ?? 0;
566
586
  console.log(red(`\n⚠ Loop terdeteksi: "${loopName}" dipanggil ${total}× — agen terjebak.`));
567
587
  console.log(yellow('Berikan instruksi baru, atau ketik /bersih untuk mulai ulang.'));
568
- const loopMsg = `⚠ LOOP: "${loopName}" sudah dipanggil ${total}× dengan argumen yang sama. JANGAN panggil lagi. Gunakan tool "selesai" dan jelaskan kendalanya, atau minta klarifikasi dari pengguna.`;
588
+ const loopMsg = `⚠ LOOP TERDETEKSI: "${loopName}" sudah dipanggil ${total}× BERHENTI SEKARANG.
589
+
590
+ Panggil tool "selesai" dengan ringkasan yang berisi:
591
+ 1. Apa yang SUDAH berhasil dikerjakan (file diubah, perintah dijalankan)
592
+ 2. Apa yang BELUM selesai dan kendala yang ditemui
593
+ 3. Saran langkah berikutnya untuk pengguna
594
+
595
+ JANGAN panggil tool lain selain "selesai".`;
569
596
  for (const call of calls) {
570
597
  s.messages.push({ role: 'tool', content: loopMsg, tool_call_id: call.id });
571
598
  }
@@ -588,9 +615,13 @@ export async function runTurn(s, userText) {
588
615
  // trimContext drops old messages from its window. The escalating warning text
589
616
  // discourages repetition; withholding the content at count=3+ only causes the model
590
617
  // to keep retrying — counter-productive to stopping the loop.
591
- const stopNote = count >= COUNT_LIMIT - 1
592
- ? `\n\n⚠ STOP (ke-${count}): JANGAN panggil "${toolName}" lagi dengan argumen yang sama. Gunakan tool "selesai" dan jelaskan kendalanya, atau gunakan cari/baca_file dengan path/range BERBEDA.`
593
- : `\n\n⚠ Cache ke-${count}: "${toolName}" sudah dipanggil ${count}× dengan argumen sama. Gunakan cari atau baca_file(path, mulai=N) untuk bagian berbeda.`;
618
+ const toolLimit = countLimitFor(toolName);
619
+ const isLastChance = count >= toolLimit - 1;
620
+ const stopNote = isLastChance
621
+ ? `\n\n⚠ STOP (ke-${count}/${toolLimit}): JANGAN panggil "${toolName}" lagi dengan argumen yang sama. Gunakan tool "selesai" dan jelaskan kendalanya, atau gunakan cari/baca_file dengan path/range BERBEDA.`
622
+ : toolName === 'daftar_file'
623
+ ? `\n\n⚠ Cache ke-${count}: direktori ini SUDAH terdaftar. JANGAN list ulang — gunakan baca_file(path) untuk file spesifik di dalamnya.`
624
+ : `\n\n⚠ Cache ke-${count}: "${toolName}" sudah dipanggil ${count}× dengan argumen sama. Gunakan cari atau baca_file(path, mulai=N) untuk bagian berbeda.`;
594
625
  const warn = `[Cache ke-${count}]\n${cachedOut}${stopNote}`;
595
626
  const cachePathHint = (() => {
596
627
  try {
@@ -604,6 +635,45 @@ export async function runTurn(s, userText) {
604
635
  s.messages.push({ role: 'tool', content: warn, tool_call_id: call.id });
605
636
  continue;
606
637
  }
638
+ // MCP tool dispatch — routed to the connected MCP server
639
+ if (call.function.name.startsWith('mcp__') && s.mcpManager) {
640
+ let mcpArgs = {};
641
+ try {
642
+ mcpArgs = JSON.parse(call.function.arguments || '{}');
643
+ }
644
+ catch {
645
+ s.messages.push({
646
+ role: 'tool',
647
+ content: 'Error: argumen bukan JSON valid.',
648
+ tool_call_id: call.id,
649
+ });
650
+ continue;
651
+ }
652
+ const [, serverPart, ...toolParts] = call.function.name.split('__');
653
+ console.log(dim(` mcp:${serverPart} → ${toolParts.join('__')}`));
654
+ if (!(await allow(s.otomatis, `Jalankan MCP tool "${call.function.name}"?`))) {
655
+ s.messages.push({
656
+ role: 'tool',
657
+ content: 'Ditolak oleh pengguna.',
658
+ tool_call_id: call.id,
659
+ });
660
+ continue;
661
+ }
662
+ let mcpOut;
663
+ try {
664
+ mcpOut = await s.mcpManager.callTool(call.function.name, mcpArgs);
665
+ }
666
+ catch (err) {
667
+ mcpOut = `Error MCP: ${err instanceof Error ? err.message : String(err)}`;
668
+ }
669
+ const MAX_STORED_RESULT_MCP = 10_000;
670
+ const stored = mcpOut.length > MAX_STORED_RESULT_MCP
671
+ ? `${mcpOut.slice(0, MAX_STORED_RESULT_MCP)}\n…[dipotong]`
672
+ : mcpOut;
673
+ s.messages.push({ role: 'tool', content: stored, tool_call_id: call.id });
674
+ saveCheckpoint(s);
675
+ continue;
676
+ }
607
677
  const result = await runTool(call, s.otomatis, s.model);
608
678
  // Cap what goes into the conversation history: full output can be 300 lines of directory
609
679
  // listing or 100 KB of file content, which quickly overflows small-context-window models
@@ -633,6 +703,7 @@ export async function runTurn(s, userText) {
633
703
  console.log(green(`\n✓ Selesai: ${result.output}`));
634
704
  showUsage();
635
705
  s.totalTokens += totalIn + totalOut;
706
+ s._lastCompleted = true;
636
707
  clearCheckpoint();
637
708
  return result.output;
638
709
  }
@@ -651,5 +722,19 @@ export async function runTurn(s, userText) {
651
722
  }
652
723
  /** One-shot: run a single task in a mode (used by the `rencana`/`edit`/`buat` subcommands). */
653
724
  export async function runAgent(task, mode, opts) {
654
- await runTurn(newSession(opts.model, mode, opts.otomatis), task);
725
+ const s = newSession(opts.model, mode, opts.otomatis);
726
+ const mcpServers = loadMcpServers();
727
+ let mcpManager;
728
+ if (Object.keys(mcpServers).length > 0) {
729
+ mcpManager = new McpManager();
730
+ await mcpManager.connect(mcpServers);
731
+ if (mcpManager.toolCount > 0)
732
+ s.mcpManager = mcpManager;
733
+ }
734
+ try {
735
+ await runTurn(s, task);
736
+ }
737
+ finally {
738
+ mcpManager?.disconnect();
739
+ }
655
740
  }
@@ -0,0 +1,84 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { SKILLS_GLOBAL_DIR, projectSkillsDir } from '../config.js';
4
+ function parseFrontmatter(content) {
5
+ if (!content.startsWith('---\n'))
6
+ return { meta: {}, body: content.trim() };
7
+ const end = content.indexOf('\n---\n', 4);
8
+ if (end === -1)
9
+ return { meta: {}, body: content.trim() };
10
+ const raw = content.slice(4, end);
11
+ const meta = {};
12
+ for (const line of raw.split('\n')) {
13
+ const trimmed = line.trim();
14
+ if (trimmed.startsWith('#'))
15
+ continue;
16
+ const match = trimmed.match(/^(\w+):\s*(.*)$/);
17
+ if (match?.[1])
18
+ meta[match[1]] = match[2]?.trim() ?? '';
19
+ }
20
+ return { meta, body: content.slice(end + 5).trim() };
21
+ }
22
+ function readSkillFile(filePath, source) {
23
+ try {
24
+ const content = readFileSync(filePath, 'utf8');
25
+ const { meta, body } = parseFrontmatter(content);
26
+ const name = filePath.split(/[/\\]/).pop()?.replace(/\.md$/, '') ?? '';
27
+ if (!name || !body)
28
+ return null;
29
+ const modeRaw = meta.mode ?? 'buat';
30
+ const mode = modeRaw === 'rencana' || modeRaw === 'edit' ? modeRaw : 'buat';
31
+ return {
32
+ name,
33
+ description: meta.description ?? '',
34
+ prompt: body,
35
+ mode,
36
+ otomatis: meta.otomatis === 'true',
37
+ model: meta.model || undefined,
38
+ source,
39
+ filePath,
40
+ };
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /** Load a skill by name: project (.kiosapi/skills/) overrides global (~/.kiosapi/skills/). */
47
+ export function loadSkill(name) {
48
+ const projectPath = join(projectSkillsDir(), `${name}.md`);
49
+ if (existsSync(projectPath))
50
+ return readSkillFile(projectPath, 'project');
51
+ const globalPath = join(SKILLS_GLOBAL_DIR, `${name}.md`);
52
+ if (existsSync(globalPath))
53
+ return readSkillFile(globalPath, 'global');
54
+ return null;
55
+ }
56
+ /** List all available skills (project overrides global on name collision). */
57
+ export function listSkills() {
58
+ const skills = new Map();
59
+ // Load global first so project can override
60
+ for (const { dir, source } of [
61
+ { dir: SKILLS_GLOBAL_DIR, source: 'global' },
62
+ { dir: projectSkillsDir(), source: 'project' },
63
+ ]) {
64
+ if (!existsSync(dir))
65
+ continue;
66
+ try {
67
+ for (const file of readdirSync(dir)) {
68
+ if (!file.endsWith('.md'))
69
+ continue;
70
+ const sk = readSkillFile(join(dir, file), source);
71
+ if (sk)
72
+ skills.set(sk.name, sk);
73
+ }
74
+ }
75
+ catch {
76
+ /* unreadable dir — ignore */
77
+ }
78
+ }
79
+ return [...skills.values()].sort((a, b) => {
80
+ if (a.source !== b.source)
81
+ return a.source === 'project' ? -1 : 1;
82
+ return a.name.localeCompare(b.name);
83
+ });
84
+ }