dsh-paper-search 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/LICENSE +21 -0
- package/NOTICE.md +50 -0
- package/README.md +151 -0
- package/cordis.patch.yml +17 -0
- package/lib/index.js +381 -0
- package/lib/sources.js +576 -0
- package/package.json +50 -0
- package/skills/paper-search/SKILL.md +148 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// lib/index.js — plugin DSH `dsh-paper-search`.
|
|
2
|
+
//
|
|
3
|
+
// TIGA HAL YANG DILAKUKAN PLUGIN INI:
|
|
4
|
+
// 1. mendaftarkan skill `paper-search` lewat `ctx.get('skills')`, di rank 450
|
|
5
|
+
// supaya KALAH dari berkas di `~/.dsh/skills` (rank 400) — jadi suntingan
|
|
6
|
+
// pengguna selalu menang, dan registrasi ini hanya cadangan untuk keadaan
|
|
7
|
+
// provider preset mati (mis. preset "Creator mode");
|
|
8
|
+
// 2. tool `paper_search` — pencarian literatur lintas sumber;
|
|
9
|
+
// 3. tool `paper_status` — "doctor": sumber mana yang menjawab dan kredensial
|
|
10
|
+
// mana yang terbaca. Ini jawaban langsung atas masalah yang berulang di
|
|
11
|
+
// tool sejenis: kegagalan yang menyamar jadi "0 hasil".
|
|
12
|
+
//
|
|
13
|
+
// KREDENSIAL: tidak ada kunci yang dibawa paket ini. Urutan pembacaannya
|
|
14
|
+
// 1. config plugin (dari profil pengguna)
|
|
15
|
+
// 2. variabel lingkungan PAPER_SEARCH_MCP_<NAMA>, lalu <NAMA>
|
|
16
|
+
// 3. berkas ~/.config/paper-search-mcp/.env (milik CLI upstream)
|
|
17
|
+
// Langkah 3 ada supaya mesin yang sudah disetel untuk `paper-search-mcp` tidak
|
|
18
|
+
// perlu konfigurasi ulang.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
import { SUMBER, ID_SUMBER, ID_INTERNASIONAL, ID_INDONESIA, searchSource, dedup, cariSumber, apakahDoi } from './sources.js';
|
|
26
|
+
|
|
27
|
+
export const name = 'paper-search';
|
|
28
|
+
// `skills` dan `fs` sengaja TIDAK di-inject: keduanya opsional, dibaca lewat
|
|
29
|
+
// ctx.get(). Tanpa itu plugin tetap harus mendaftarkan tool-nya.
|
|
30
|
+
export const inject = ['tools'];
|
|
31
|
+
|
|
32
|
+
const DIR_PAKET = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
33
|
+
const BERKAS_SKILL = join(DIR_PAKET, 'skills', 'paper-search', 'SKILL.md');
|
|
34
|
+
const PREFIX = 'PAPER_SEARCH_MCP_';
|
|
35
|
+
|
|
36
|
+
// ── Kredensial ───────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/** Baca .env milik CLI upstream, kalau ada. Tidak pernah melempar. */
|
|
39
|
+
function bacaEnvUpstream() {
|
|
40
|
+
const keluaran = {};
|
|
41
|
+
try {
|
|
42
|
+
const p = join(homedir(), '.config', 'paper-search-mcp', '.env');
|
|
43
|
+
if (!existsSync(p)) return keluaran;
|
|
44
|
+
for (const baris of readFileSync(p, 'utf8').split(/\r?\n/)) {
|
|
45
|
+
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(baris);
|
|
46
|
+
if (m && m[2].trim()) keluaran[m[1]] = m[2].trim();
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Berkas tidak terbaca = tidak ada kredensial dari jalur ini. Bukan galat.
|
|
50
|
+
}
|
|
51
|
+
return keluaran;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buatPembacaKredensial(config) {
|
|
55
|
+
const dariBerkas = bacaEnvUpstream();
|
|
56
|
+
return function ambil(nama) {
|
|
57
|
+
if (config && config[nama]) return { nilai: String(config[nama]), dari: 'config plugin' };
|
|
58
|
+
const env = process.env[PREFIX + nama] ?? process.env[nama];
|
|
59
|
+
if (env) return { nilai: String(env), dari: 'variabel lingkungan' };
|
|
60
|
+
if (dariBerkas[PREFIX + nama]) return { nilai: dariBerkas[PREFIX + nama], dari: '.env upstream' };
|
|
61
|
+
if (dariBerkas[nama]) return { nilai: dariBerkas[nama], dari: '.env upstream (nama lama)' };
|
|
62
|
+
return { nilai: null, dari: null };
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Email apa pun yang tersedia — dipakai untuk "polite pool" Crossref/OpenAlex. */
|
|
67
|
+
function emailPolite(ambil) {
|
|
68
|
+
return ambil('UNPAYWALL_EMAIL').nilai || ambil('OPENALEX_EMAIL').nilai || null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Pencarian ────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/** Terjemahkan pilihan pengguna menjadi daftar id sumber. */
|
|
74
|
+
function pilihSumber(permintaan) {
|
|
75
|
+
const t = String(permintaan ?? '').trim().toLowerCase();
|
|
76
|
+
if (!t || t === 'all' || t === 'semua' || t === '*') return { sah: ID_SUMBER, takDikenal: [], eksplisit: false };
|
|
77
|
+
if (t === 'international' || t === 'internasional') return { sah: ID_INTERNASIONAL, takDikenal: [], eksplisit: false };
|
|
78
|
+
if (t === 'indonesia' || t === 'lokal') return { sah: ID_INDONESIA, takDikenal: [], eksplisit: false };
|
|
79
|
+
const sah = [];
|
|
80
|
+
const takDikenal = [];
|
|
81
|
+
for (const id of t.split(/[,\s]+/).filter(Boolean)) (cariSumber(id) ? sah : takDikenal).push(id);
|
|
82
|
+
return { sah, takDikenal, eksplisit: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function jalankanPencarian(args, kredensial) {
|
|
86
|
+
const query = String(args?.query ?? '').trim();
|
|
87
|
+
if (!query) throw new Error('parameter "query" wajib diisi');
|
|
88
|
+
|
|
89
|
+
const queryDoi = apakahDoi(query);
|
|
90
|
+
const pilihan = pilihSumber(args?.sources);
|
|
91
|
+
if (pilihan.sah.length === 0) {
|
|
92
|
+
throw new Error(`tidak ada sumber yang sah. Yang tersedia: ${ID_SUMBER.join(', ')}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Sumber ber-input DOI (unpaywall) hanya ikut kalau query-nya memang DOI.
|
|
96
|
+
// Kalau diminta EKSPLISIT, ia tetap dijalankan — hasilnya kosong dan itu
|
|
97
|
+
// dijelaskan kepada pengguna, bukan dibuang diam-diam.
|
|
98
|
+
const ids = [];
|
|
99
|
+
const dilewati = [];
|
|
100
|
+
for (const id of pilihan.sah) {
|
|
101
|
+
if (!queryDoi && cariSumber(id)?.input === 'doi' && !pilihan.eksplisit) { dilewati.push(id); continue; }
|
|
102
|
+
ids.push(id);
|
|
103
|
+
}
|
|
104
|
+
if (ids.length === 0) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`semua sumber yang dipilih ber-input DOI (${dilewati.join(', ')}), sedangkan query ini bukan DOI. `
|
|
107
|
+
+ 'Sertakan sumber lain, atau berikan DOI-nya.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const rows = Math.min(Math.max(Number(args?.max_results) || 5, 1), 50);
|
|
112
|
+
const email = emailPolite(kredensial);
|
|
113
|
+
const opsi = {
|
|
114
|
+
rows,
|
|
115
|
+
email,
|
|
116
|
+
apiKey: kredensial('SEMANTIC_SCHOLAR_API_KEY').nilai,
|
|
117
|
+
doajApiKey: kredensial('DOAJ_API_KEY').nilai,
|
|
118
|
+
coreApiKey: kredensial('CORE_API_KEY').nilai,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const perSumber = await Promise.all(ids.map((id) => searchSource(id, query, opsi)));
|
|
122
|
+
|
|
123
|
+
let papers = dedup(perSumber.flatMap((h) => h.papers));
|
|
124
|
+
if (args?.open_access_only) papers = papers.filter((p) => p.openAccess);
|
|
125
|
+
const tahun = Number(args?.year_from);
|
|
126
|
+
if (Number.isFinite(tahun) && tahun > 1500) papers = papers.filter((p) => !p.year || p.year >= tahun);
|
|
127
|
+
|
|
128
|
+
return { query, rows, ids, takDikenal: pilihan.takDikenal, dilewati, perSumber, papers };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Satu baris status sumber — DIPAKAI KEDUA TOOL supaya penandaannya identik.
|
|
133
|
+
* Sebelumnya tool pencarian memakai "v/x" dan tool status memakai "✓/✗", dan
|
|
134
|
+
* ketidakcocokan itu membuat uji sendiri gagal. Teks polos juga lebih aman di
|
|
135
|
+
* terminal mana pun.
|
|
136
|
+
*/
|
|
137
|
+
function barisSumber(label, h, keterangan = '') {
|
|
138
|
+
const l = String(label).padEnd(18);
|
|
139
|
+
return h.error
|
|
140
|
+
? ` GAGAL ${l} ${keterangan}${h.error}`
|
|
141
|
+
: ` OK ${l} ${keterangan}${String(h.papers.length).padStart(3)} hasil (${h.ms} ms)`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function formatHasil(hasil) {
|
|
145
|
+
const { perSumber, papers } = hasil;
|
|
146
|
+
const gagal = perSumber.filter((h) => h.error);
|
|
147
|
+
const baris = [];
|
|
148
|
+
|
|
149
|
+
baris.push(`query: ${hasil.query}`);
|
|
150
|
+
baris.push(`${perSumber.length} sumber dicari · ${perSumber.length - gagal.length} menjawab · ${papers.length} paper unik setelah dedup`);
|
|
151
|
+
if (hasil.takDikenal?.length) baris.push(`PERHATIAN: sumber tidak dikenal (diabaikan): ${hasil.takDikenal.join(', ')}`);
|
|
152
|
+
if (hasil.dilewati?.length) {
|
|
153
|
+
baris.push(`CATATAN: ${hasil.dilewati.join(', ')} dilewati karena query ini bukan DOI — sumber itu memang mencari dengan DOI, bukan kata kunci.`);
|
|
154
|
+
}
|
|
155
|
+
baris.push('');
|
|
156
|
+
baris.push('per sumber:');
|
|
157
|
+
for (const h of [...perSumber].sort((a, b) => (b.papers.length - a.papers.length) || a.id.localeCompare(b.id))) {
|
|
158
|
+
const s = cariSumber(h.id);
|
|
159
|
+
const jenis = `${s?.kind ?? ''}${s?.input === 'doi' ? '/doi' : ''}`.padEnd(8);
|
|
160
|
+
baris.push(barisSumber(s?.label ?? h.id, h, jenis));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (gagal.length) {
|
|
164
|
+
baris.push('');
|
|
165
|
+
baris.push('PERHATIAN: sumber bertanda GAGAL di atas HARUS disebutkan saat melaporkan hasil —');
|
|
166
|
+
baris.push('jangan menyimpulkan "tidak ada literatur" dari sumber yang gagal.');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
baris.push('');
|
|
170
|
+
if (papers.length === 0) {
|
|
171
|
+
baris.push('TIDAK ADA hasil yang lolos filter.');
|
|
172
|
+
baris.push('Sebelum menyimpulkan "tidak ada literatur": ulangi sekali, lalu coba sumber lain.');
|
|
173
|
+
return baris.join('\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
papers.slice(0, 40).forEach((p, i) => {
|
|
177
|
+
const penulis = p.authors.length ? p.authors.slice(0, 3).join('; ') + (p.authors.length > 3 ? ' dkk' : '') : '(penulis tidak dilaporkan)';
|
|
178
|
+
baris.push(`${i + 1}. ${p.title}`);
|
|
179
|
+
baris.push(` ${p.year ?? '?'} · ${p.journal ?? 'jurnal tidak dilaporkan'} · ${penulis}`);
|
|
180
|
+
baris.push(` sumber: ${p.sources.join(', ')}${p.openAccess ? ' · akses terbuka' : ''}`);
|
|
181
|
+
if (p.doi) baris.push(` doi: ${p.doi}`);
|
|
182
|
+
else if (p.url) baris.push(` url: ${p.url}`);
|
|
183
|
+
if (p.abstract) baris.push(` abstrak: ${p.abstract}`);
|
|
184
|
+
});
|
|
185
|
+
if (papers.length > 40) baris.push(`… dan ${papers.length - 40} lagi (persempit query atau turunkan max_results).`);
|
|
186
|
+
return baris.join('\n');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Tool ─────────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
function toolSearch(kredensial) {
|
|
192
|
+
return {
|
|
193
|
+
name: 'paper_search',
|
|
194
|
+
description:
|
|
195
|
+
'Cari literatur ilmiah dari 13 sumber internasional (Crossref, OpenAlex, DOAJ, Europe PMC, PubMed, PubMed Central, arXiv, Semantic Scholar, CORE, Zenodo, HAL, IACR ePrint, Unpaywall) dan 3 portal jurnal Indonesia (Garuda, Sinta, IOS OneSearch) sekaligus, lalu buang duplikatnya berdasarkan DOI/judul. '
|
|
196
|
+
+ 'Semua native lewat HTTP — tidak butuh CLI atau paket luar. '
|
|
197
|
+
+ 'Pakai untuk: mencari paper/jurnal, penelitian terdahulu, literatur Indonesia, atau melacak DOI. '
|
|
198
|
+
+ 'Keluaran memisahkan sumber yang GAGAL dari yang menjawab, jadi "0 hasil" dari satu sumber tidak boleh disimpulkan sebagai "tidak ada literatur".',
|
|
199
|
+
parameters: {
|
|
200
|
+
type: 'object',
|
|
201
|
+
properties: {
|
|
202
|
+
query: { type: 'string', description: 'Kata kunci. Untuk topik Indonesia pakai kata kunci bahasa Indonesia — hasilnya jauh lebih relevan.' },
|
|
203
|
+
sources: { type: 'string', description: `Daftar id dipisah koma, atau "all" / "international" / "indonesia". Id yang tersedia: ${ID_SUMBER.join(', ')}. Bawaan: semua.` },
|
|
204
|
+
max_results: { type: 'number', description: 'Hasil per sumber (1-50, bawaan 5).' },
|
|
205
|
+
open_access_only: { type: 'boolean', description: 'Hanya kembalikan paper akses-terbuka (bila sumber melaporkannya).' },
|
|
206
|
+
year_from: { type: 'number', description: 'Hanya paper dengan tahun >= nilai ini.' },
|
|
207
|
+
},
|
|
208
|
+
required: ['query'],
|
|
209
|
+
},
|
|
210
|
+
execute: async (args) => {
|
|
211
|
+
const hasil = await jalankanPencarian(args ?? {}, kredensial);
|
|
212
|
+
return formatHasil(hasil);
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function toolStatus(kredensial) {
|
|
218
|
+
return {
|
|
219
|
+
name: 'paper_status',
|
|
220
|
+
description:
|
|
221
|
+
'Periksa kesehatan pencarian literatur: sumber mana yang benar-benar MENJAWAB sekarang (bukan sekadar terdaftar), kredensial mana yang terbaca dan dari mana asalnya, serta apakah CLI paper-search-mcp opsional terpasang. '
|
|
222
|
+
+ 'Pakai sebelum melaporkan "tidak ada hasil", atau saat pengguna bertanya kenapa pencarian kosong. '
|
|
223
|
+
+ 'Secara bawaan ia mengirim satu permintaan kecil ke setiap sumber.',
|
|
224
|
+
parameters: {
|
|
225
|
+
type: 'object',
|
|
226
|
+
properties: {
|
|
227
|
+
probe: { type: 'boolean', description: 'Kirim satu permintaan kecil ke tiap sumber untuk membuktikan ia hidup (bawaan true). Setel false untuk memeriksa konfigurasi saja tanpa jaringan.' },
|
|
228
|
+
sources: { type: 'string', description: 'Batasi pemeriksaan ke sebagian sumber (id dipisah koma). Bawaan: semua.' },
|
|
229
|
+
},
|
|
230
|
+
required: [],
|
|
231
|
+
},
|
|
232
|
+
execute: async (args) => {
|
|
233
|
+
const probe = args?.probe !== false;
|
|
234
|
+
const barisPilihan = pilihSumber(args?.sources);
|
|
235
|
+
const ids = barisPilihan.sah.length ? barisPilihan.sah : ID_SUMBER;
|
|
236
|
+
const keluar = [];
|
|
237
|
+
keluar.push('== kredensial ==');
|
|
238
|
+
for (const nama of ['UNPAYWALL_EMAIL', 'OPENALEX_EMAIL', 'SEMANTIC_SCHOLAR_API_KEY', 'DOAJ_API_KEY', 'CORE_API_KEY']) {
|
|
239
|
+
const k = kredensial(nama);
|
|
240
|
+
const nilaiTampil = k.nilai
|
|
241
|
+
? (nama.endsWith('EMAIL') ? k.nilai : `terisi (${k.nilai.length} huruf)`)
|
|
242
|
+
: 'KOSONG';
|
|
243
|
+
keluar.push(` ${nama.padEnd(30)} ${nilaiTampil}${k.dari ? ` ← ${k.dari}` : ''}`);
|
|
244
|
+
}
|
|
245
|
+
keluar.push(' catatan: OpenAIRE dan CiteSeerX tidak dipakai plugin ini; IEEE dan ACM tidak dipakai karena konektornya di upstream masih kerangka.');
|
|
246
|
+
|
|
247
|
+
keluar.push('');
|
|
248
|
+
keluar.push('== sumber ==');
|
|
249
|
+
const email = emailPolite(kredensial);
|
|
250
|
+
const opsi = { rows: 1, email, apiKey: kredensial('SEMANTIC_SCHOLAR_API_KEY').nilai, doajApiKey: kredensial('DOAJ_API_KEY').nilai, coreApiKey: kredensial('CORE_API_KEY').nilai };
|
|
251
|
+
for (const id of ids) {
|
|
252
|
+
const s = cariSumber(id);
|
|
253
|
+
const jenis = `${s.kind}${s.input === 'doi' ? '/doi' : ''}`.padEnd(8);
|
|
254
|
+
if (!probe) {
|
|
255
|
+
keluar.push(` ? ${s.label.padEnd(18)} ${jenis} terdaftar (probe dimatikan)`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
// Sumber ber-input DOI diuji dengan DOI sungguhan — kalau diuji dengan
|
|
259
|
+
// kata kunci ia selalu 0 dan itu terbaca seolah sumbernya rusak.
|
|
260
|
+
const kueri = s.input === 'doi' ? '10.1038/s41580-023-00697-6' : 'literature';
|
|
261
|
+
const h = await searchSource(id, kueri, opsi);
|
|
262
|
+
keluar.push(barisSumber(s.label, h, jenis));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
keluar.push('');
|
|
266
|
+
keluar.push('== CLI paper-search-mcp (opsional) ==');
|
|
267
|
+
keluar.push(` Plugin ini TIDAK memerlukannya — ${SUMBER.length} sumber di atas sudah native.`);
|
|
268
|
+
keluar.push(' Kalau terpasang, ia menambah sumber lain (CORE, Zenodo, HAL, SSRN, Unpaywall, dll).');
|
|
269
|
+
keluar.push(' Pasang: uv tool install --force "git+https://github.com/openags/paper-search-mcp"');
|
|
270
|
+
return keluar.join('\n');
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Skill ────────────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
/** Pengurai frontmatter datar — cukup untuk SKILL.md milik paket ini. */
|
|
278
|
+
function uraiFrontmatter(mentah) {
|
|
279
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(mentah);
|
|
280
|
+
if (!m) return null;
|
|
281
|
+
const blok = m[1];
|
|
282
|
+
const badan = mentah.slice(m[0].length).trim();
|
|
283
|
+
const ambil = (kunci) => {
|
|
284
|
+
const a = new RegExp(`^${kunci}:\\s*"([^"]*)"`, 'm').exec(blok);
|
|
285
|
+
if (a) return a[1].trim();
|
|
286
|
+
const b = new RegExp(`^${kunci}:\\s*(.+)$`, 'm').exec(blok);
|
|
287
|
+
return b ? b[1].trim() : '';
|
|
288
|
+
};
|
|
289
|
+
const nama = ambil('name');
|
|
290
|
+
if (!nama) return null;
|
|
291
|
+
return {
|
|
292
|
+
nama,
|
|
293
|
+
deskripsi: ambil('description'),
|
|
294
|
+
kapanPakai: ambil('whenToUse'),
|
|
295
|
+
// Kebijakan invocation dihormati DARI BERKAS, jangan dipaksakan `true`.
|
|
296
|
+
modelInvocable: ambil('disable-model-invocation').toLowerCase() !== 'true',
|
|
297
|
+
userInvocable: ambil('user-invocable').toLowerCase() !== 'false',
|
|
298
|
+
badan,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function daftarkanSkill(ctx, notes) {
|
|
303
|
+
const skills = ctx.get('skills');
|
|
304
|
+
if (!skills || typeof skills.registerProvider !== 'function') {
|
|
305
|
+
notes.push('service skills tidak tersedia — skill hanya terbaca dari berkas');
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const baca = async () => {
|
|
309
|
+
try {
|
|
310
|
+
const fsSvc = ctx.get('fs');
|
|
311
|
+
let mentah;
|
|
312
|
+
if (fsSvc && typeof fsSvc.readText === 'function') {
|
|
313
|
+
mentah = await fsSvc.readText(await fsSvc.resolve(BERKAS_SKILL));
|
|
314
|
+
} else {
|
|
315
|
+
if (!existsSync(BERKAS_SKILL)) return null;
|
|
316
|
+
mentah = readFileSync(BERKAS_SKILL, 'utf8');
|
|
317
|
+
}
|
|
318
|
+
const isi = uraiFrontmatter(mentah);
|
|
319
|
+
// Nama di frontmatter harus cocok — kalau tidak, ini bukan skill kita.
|
|
320
|
+
return isi && isi.nama === 'paper-search' ? isi : null;
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
const ringkas = (isi) => ({
|
|
326
|
+
name: isi.nama,
|
|
327
|
+
description: isi.deskripsi || '',
|
|
328
|
+
...(isi.kapanPakai ? { whenToUse: isi.kapanPakai } : {}),
|
|
329
|
+
invocation: { modelInvocable: isi.modelInvocable, userInvocable: isi.userInvocable },
|
|
330
|
+
source: 'runtime',
|
|
331
|
+
provider: 'paper-search-runtime',
|
|
332
|
+
// Rank 450: DI ATAS 400 (~/.dsh/skills) supaya kalah dari berkas yang
|
|
333
|
+
// disunting pengguna, tetapi masih menang atas user-agents/bundled (500/600).
|
|
334
|
+
rank: 450,
|
|
335
|
+
locator: { berkas: BERKAS_SKILL },
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
ctx.effect(() => skills.registerProvider(() => ({
|
|
339
|
+
name: 'paper-search-runtime',
|
|
340
|
+
// Dibaca SETIAP kali (tidak di-cache) supaya suntingan SKILL.md langsung
|
|
341
|
+
// terpakai tanpa restart DSH.
|
|
342
|
+
list: async () => {
|
|
343
|
+
const isi = await baca();
|
|
344
|
+
return isi ? [ringkas(isi)] : [];
|
|
345
|
+
},
|
|
346
|
+
get: async (kandidat) => {
|
|
347
|
+
if (kandidat?.name !== 'paper-search') return undefined;
|
|
348
|
+
const isi = await baca();
|
|
349
|
+
if (!isi) return undefined;
|
|
350
|
+
return {
|
|
351
|
+
...ringkas(isi),
|
|
352
|
+
content: isi.badan,
|
|
353
|
+
resourceBase: { kind: 'directory', path: dirname(BERKAS_SKILL) },
|
|
354
|
+
};
|
|
355
|
+
},
|
|
356
|
+
})));
|
|
357
|
+
notes.push('skill paper-search terdaftar di layer global (rank 450, cadangan)');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── apply ────────────────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
export function apply(ctx, config) {
|
|
363
|
+
const notes = [];
|
|
364
|
+
const kredensial = buatPembacaKredensial(config);
|
|
365
|
+
|
|
366
|
+
ctx.effect(() => ctx.tools.register(toolSearch(kredensial)));
|
|
367
|
+
notes.push('tool paper_search terdaftar');
|
|
368
|
+
ctx.effect(() => ctx.tools.register(toolStatus(kredensial)));
|
|
369
|
+
notes.push('tool paper_status terdaftar');
|
|
370
|
+
|
|
371
|
+
daftarkanSkill(ctx, notes);
|
|
372
|
+
|
|
373
|
+
if (SUMBER.length !== 16) {
|
|
374
|
+
// Penjaga: deskripsi paket, README, SKILL.md, CHANGELOG, dan entri katalog
|
|
375
|
+
// semuanya menyebut JUMLAH sumber. Kalau daftar ini berubah, kelimanya harus
|
|
376
|
+
// ikut — dan penilai katalog memang mencocokkan angka di deskripsi ke kode.
|
|
377
|
+
notes.push(`PERINGATAN: jumlah sumber ${SUMBER.length}, bukan 16 — perbarui deskripsi package.json, README, SKILL.md, CHANGELOG, dan entri katalog`);
|
|
378
|
+
}
|
|
379
|
+
notes.push(`${ID_INTERNASIONAL.length} sumber internasional + ${ID_INDONESIA.length} sumber Indonesia`);
|
|
380
|
+
return notes.join(' · ');
|
|
381
|
+
}
|