sneakoscope 0.3.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.
@@ -0,0 +1,53 @@
1
+ export const QUESTION_PATTERNS = [
2
+ /\?/,
3
+ /어떻게\s*할까요/i,
4
+ /확인해\s*주세요/i,
5
+ /선택해\s*주세요/i,
6
+ /알려\s*주세요/i,
7
+ /답변(?:이|을)?\s*필요/i,
8
+ /사용자.*(?:결정|입력|확인|답변)/i,
9
+ /진행해도\s*될까요/i,
10
+ /원하시나요/i,
11
+ /please\s+confirm/i,
12
+ /would\s+you\s+like/i,
13
+ /should\s+i/i,
14
+ /need\s+your\s+(input|confirmation|decision)/i,
15
+ /which\s+option\s+do\s+you\s+prefer/i
16
+ ];
17
+
18
+ export function containsUserQuestion(text = '') {
19
+ if (!text) return false;
20
+ return QUESTION_PATTERNS.some((p) => p.test(text));
21
+ }
22
+
23
+ export function noQuestionContinuationReason() {
24
+ return [
25
+ 'RALPH_NO_QUESTION_LOCK is active.',
26
+ 'Do not ask the user, do not request confirmation, and do not present choices.',
27
+ 'Resolve using decision-contract.json: seed contract → explicit user answers → approved defaults → AGENTS.md → current code/tests → smallest reversible change → defer optional scope.',
28
+ 'If a risky required path has no safe resolution, complete the safe core scope and record limitation in done-gate.json; do not ask the user mid-loop.',
29
+ 'Continue the Ralph loop.'
30
+ ].join(' ');
31
+ }
32
+
33
+ export function interactiveCommandReason(command = '') {
34
+ return `Interactive or approval-seeking command is forbidden during Ralph no-question mode: ${command}`;
35
+ }
36
+
37
+ export function looksInteractiveCommand(command = '') {
38
+ const patterns = [
39
+ /(^|\s)read\s+-?p?\b/i,
40
+ /(^|\s)select\s+/i,
41
+ /(^|\s)sudo\b/i,
42
+ /git\s+rebase\s+-i/i,
43
+ /(^|\s)(npm|pnpm|yarn)\s+init\b/i,
44
+ /(^|\s)(npm|pnpm|yarn)\s+create\b/i,
45
+ /(^|\s)ssh\b/i,
46
+ /mysql\b.*\s-p(\s|$)/i,
47
+ /psql\b.*\s-W(\s|$)/i,
48
+ /(^|\s)rm\s+-i\b/i,
49
+ /(^|\s)mv\s+-i\b/i,
50
+ /(^|\s)cp\s+-i\b/i
51
+ ];
52
+ return patterns.some((p) => p.test(command));
53
+ }
@@ -0,0 +1,99 @@
1
+ import path from 'node:path';
2
+ import { writeJsonAtomic, writeTextAtomic } from './fsx.mjs';
3
+
4
+ export function buildQuestionSchema(prompt) {
5
+ const lower = prompt.toLowerCase();
6
+ const domainHints = [];
7
+ if (/결제|payment|billing|invoice|checkout|order/.test(lower)) domainHints.push('payment');
8
+ if (/로그인|auth|session|token|인증/.test(lower)) domainHints.push('auth');
9
+ if (/ui|ux|화면|버튼|modal|모달|디자인/.test(lower)) domainHints.push('uiux');
10
+ if (/db|database|schema|migration|테이블|마이그레이션|supabase|postgres|sql|mcp/.test(lower)) domainHints.push('db');
11
+ const slots = [
12
+ { id: 'GOAL_PRECISE', question: '이번 작업의 최종 목표를 한 문장으로 정확히 정의해주세요.', required: true, type: 'string' },
13
+ { id: 'ACCEPTANCE_CRITERIA', question: '완료 기준을 항목으로 적어주세요. 최소 2개 이상 권장합니다.', required: true, type: 'array_or_string' },
14
+ { id: 'NON_GOALS', question: '이번 작업에서 제외할 범위가 있나요? 없으면 빈 배열로 답해주세요.', required: true, type: 'array_or_string' },
15
+ { id: 'PUBLIC_API_CHANGE_ALLOWED', question: 'public API 또는 외부 계약 변경을 허용하나요?', required: true, type: 'enum', options: ['no', 'yes_if_needed', 'yes'] },
16
+ { id: 'DB_SCHEMA_CHANGE_ALLOWED', question: 'DB schema 또는 migration 변경을 허용하나요?', required: true, type: 'enum', options: ['no', 'yes_if_needed', 'yes_with_migration'] },
17
+ { id: 'DEPENDENCY_CHANGE_ALLOWED', question: '새 dependency 추가를 허용하나요?', required: true, type: 'enum', options: ['no', 'yes_if_already_approved', 'yes'] },
18
+ { id: 'TEST_SCOPE', question: 'Ralph가 완료 전 실행 또는 정당화해야 할 테스트 범위를 지정해주세요.', required: true, type: 'array_or_string', examples: ['unit', 'integration', 'e2e', 'lint', 'typecheck'] },
19
+ { id: 'MID_RALPH_UNKNOWN_POLICY', question: 'Ralph 중 새 모호성이 생기면 사용자에게 묻지 않고 어떤 fallback 순서로 해결할까요?', required: true, type: 'array', options: ['preserve_existing_behavior', 'smallest_reversible_change', 'defer_optional_scope', 'block_only_if_no_safe_path'] },
20
+ { id: 'RISK_BOUNDARY', question: '보안, 결제, 데이터 손상, 권한, 인증 등 절대 넘으면 안 되는 위험 경계를 적어주세요.', required: true, type: 'array_or_string' },
21
+
22
+ { id: 'DATABASE_TARGET_ENVIRONMENT', question: 'DB 관련 작업의 대상 환경을 지정해주세요. production write는 Sneakoscope Codex가 허용하지 않습니다.', required: true, type: 'enum', options: ['no_database', 'local_dev', 'preview_branch', 'supabase_branch', 'production_read_only'] },
23
+ { id: 'DATABASE_WRITE_MODE', question: 'DB 쓰기 정책을 선택해주세요. Supabase/Postgres MCP live write는 기본 차단됩니다.', required: true, type: 'enum', options: ['read_only_only', 'migration_files_only', 'non_destructive_writes_to_local_or_branch_only'] },
24
+ { id: 'SUPABASE_MCP_POLICY', question: 'Supabase MCP를 사용한다면 어떤 안전 정책을 적용할까요?', required: true, type: 'enum', options: ['not_used', 'read_only_project_scoped_only', 'branch_only_no_live_writes'] },
25
+ { id: 'DESTRUCTIVE_DB_OPERATIONS_ALLOWED', question: 'DROP/TRUNCATE/DB reset/mass DELETE/branch reset/project delete 같은 파괴적 DB 작업을 허용하나요? Sneakoscope Codex는 never만 허용합니다.', required: true, type: 'enum', options: ['never'] },
26
+ { id: 'DB_BACKUP_OR_BRANCH_REQUIRED', question: 'DB 쓰기가 필요한 경우 local/preview branch 또는 백업이 있어야만 진행하도록 할까요?', required: true, type: 'enum', options: ['yes_for_any_write'] },
27
+ { id: 'DB_MAX_BLAST_RADIUS', question: 'DML이 꼭 필요한 경우 허용 가능한 최대 영향 범위를 적어주세요. 기본 권장값은 no_live_dml입니다.', required: true, type: 'string' }
28
+ ];
29
+ if (domainHints.includes('payment')) {
30
+ slots.push(
31
+ { id: 'PAYMENT_SUCCESS_INVARIANT', question: '이미 성공 처리된 결제에 대해서는 어떤 invariant를 보존해야 하나요?', required: true, type: 'string' },
32
+ { id: 'PAYMENT_RETRY_POLICY', question: '재시도 횟수, backoff, 실패 최종 상태 정책을 지정해주세요.', required: true, type: 'string' }
33
+ );
34
+ }
35
+ if (domainHints.includes('auth')) {
36
+ slots.push(
37
+ { id: 'AUTH_SESSION_EXPIRED_BEHAVIOR', question: '세션/토큰 만료 시 사용자가 보게 될 UX 또는 API 동작을 지정해주세요.', required: true, type: 'string' },
38
+ { id: 'AUTH_PROTOCOL_CHANGE_ALLOWED', question: '인증 프로토콜 변경을 허용하나요?', required: true, type: 'enum', options: ['no', 'yes_if_needed', 'yes'] }
39
+ );
40
+ }
41
+ if (domainHints.includes('uiux')) {
42
+ slots.push(
43
+ { id: 'UI_STATE_BEHAVIOR', question: '로딩, 에러, 빈 상태, 재시도 등 UI 상태별 기대 동작을 지정해주세요.', required: true, type: 'string' },
44
+ { id: 'VISUAL_REGRESSION_REQUIRED', question: '스크린샷/시각 검증이 필요한가요?', required: true, type: 'enum', options: ['no', 'yes_if_available', 'yes'] }
45
+ );
46
+ }
47
+ if (domainHints.includes('db')) {
48
+ slots.push(
49
+ { id: 'DB_MIGRATION_APPLY_ALLOWED', question: 'migration 적용이 필요할 경우 어디까지 허용하나요?', required: true, type: 'enum', options: ['no', 'local_only', 'preview_branch_only'] },
50
+ { id: 'DB_READ_ONLY_QUERY_LIMIT', question: 'MCP/SQL read-only 조회 시 기본 LIMIT를 몇으로 둘까요?', required: true, type: 'string' }
51
+ );
52
+ }
53
+ return {
54
+ schema_version: 1,
55
+ description: 'All required slots must be answered before Ralph can run. Ralph never asks the user after the contract is sealed. Database destructive operations are never permitted.',
56
+ prompt,
57
+ domain_hints: domainHints,
58
+ slots
59
+ };
60
+ }
61
+
62
+ export function questionsMarkdown(schema) {
63
+ const lines = [];
64
+ lines.push('# Sneakoscope Codex Ralph Prepare Questions');
65
+ lines.push('');
66
+ lines.push('Ralph는 이 질문들에 모두 답변하고 Decision Contract가 봉인된 뒤에만 실행됩니다.');
67
+ lines.push('Ralph 실행 중에는 사용자에게 절대 질문하지 않습니다.');
68
+ lines.push('DB 작업은 특히 안전 게이트가 적용됩니다. 파괴적 DB 작업은 절대 허용되지 않습니다.');
69
+ lines.push('');
70
+ for (let i = 0; i < schema.slots.length; i++) {
71
+ const s = schema.slots[i];
72
+ lines.push(`## ${i + 1}. ${s.id}`);
73
+ lines.push('');
74
+ lines.push(s.question);
75
+ if (s.options) lines.push(`- options: ${s.options.join(', ')}`);
76
+ if (s.examples) lines.push(`- examples: ${s.examples.join(', ')}`);
77
+ lines.push(`- required: ${s.required}`);
78
+ lines.push(`- type: ${s.type}`);
79
+ lines.push('');
80
+ }
81
+ lines.push('## answers.json template');
82
+ lines.push('');
83
+ lines.push('```json');
84
+ const example = {};
85
+ for (const s of schema.slots) {
86
+ if (s.type === 'array' || s.type === 'array_or_string') example[s.id] = s.options ? [s.options[0]] : [];
87
+ else if (s.options) example[s.id] = s.options[0];
88
+ else example[s.id] = s.id === 'DB_MAX_BLAST_RADIUS' ? 'no_live_dml' : '';
89
+ }
90
+ lines.push(JSON.stringify(example, null, 2));
91
+ lines.push('```');
92
+ lines.push('');
93
+ return `${lines.join('\n')}\n`;
94
+ }
95
+
96
+ export async function writeQuestions(dir, schema) {
97
+ await writeJsonAtomic(path.join(dir, 'required-answers.schema.json'), schema);
98
+ await writeTextAtomic(path.join(dir, 'questions.md'), questionsMarkdown(schema));
99
+ }
@@ -0,0 +1,140 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { exists, readJson, writeJsonAtomic, ensureDir, dirSize, fileSize, formatBytes, rmrf, nowIso, appendJsonlBounded, listFilesRecursive } from './fsx.mjs';
4
+
5
+ export const DEFAULT_RETENTION_POLICY = Object.freeze({
6
+ schema_version: 1,
7
+ max_missions: 30,
8
+ max_mission_age_days: 14,
9
+ max_sneakoscope_bytes: 256 * 1024 * 1024,
10
+ max_mission_bytes: 64 * 1024 * 1024,
11
+ max_event_log_bytes: 5 * 1024 * 1024,
12
+ max_tmp_age_hours: 2,
13
+ keep_last_cycles_per_mission: 3,
14
+ run_gc_after_each_cycle: true
15
+ });
16
+
17
+ export async function ensureRetentionPolicy(root) {
18
+ const p = path.join(root, '.sneakoscope', 'policy.json');
19
+ if (!(await exists(p))) await writeJsonAtomic(p, { retention: DEFAULT_RETENTION_POLICY });
20
+ return p;
21
+ }
22
+
23
+ export async function loadRetentionPolicy(root) {
24
+ const p = path.join(root, '.sneakoscope', 'policy.json');
25
+ const data = await readJson(p, {});
26
+ return { ...DEFAULT_RETENTION_POLICY, ...(data.retention || data || {}) };
27
+ }
28
+
29
+ export async function storageReport(root) {
30
+ const sks = path.join(root, '.sneakoscope');
31
+ const report = { root, exists: await exists(sks), generated_at: nowIso(), sections: {}, total_bytes: 0 };
32
+ if (!report.exists) return report;
33
+ for (const name of ['missions', 'memory', 'gx', 'hproof', 'tmp', 'arenas', 'state', 'model', 'genome', 'trajectories', 'locks', 'reports']) {
34
+ const p = path.join(sks, name);
35
+ const bytes = await dirSize(p).catch(() => 0);
36
+ report.sections[name] = { bytes, human: formatBytes(bytes) };
37
+ report.total_bytes += bytes;
38
+ }
39
+ report.total_human = formatBytes(report.total_bytes);
40
+ return report;
41
+ }
42
+
43
+ async function listMissionDirs(root) {
44
+ const base = path.join(root, '.sneakoscope', 'missions');
45
+ if (!(await exists(base))) return [];
46
+ const entries = await fs.readdir(base, { withFileTypes: true });
47
+ const out = [];
48
+ for (const e of entries) {
49
+ if (!e.isDirectory() || !e.name.startsWith('M-')) continue;
50
+ const p = path.join(base, e.name);
51
+ const st = await fs.stat(p).catch(() => null);
52
+ if (st) out.push({ id: e.name, path: p, mtimeMs: st.mtimeMs, size: await dirSize(p).catch(() => 0) });
53
+ }
54
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
55
+ }
56
+
57
+ async function pruneTmp(root, policy, dryRun, actions) {
58
+ const tmp = path.join(root, '.sneakoscope', 'tmp');
59
+ if (!(await exists(tmp))) return;
60
+ const now = Date.now();
61
+ const maxAge = policy.max_tmp_age_hours * 60 * 60 * 1000;
62
+ const entries = await fs.readdir(tmp, { withFileTypes: true }).catch(() => []);
63
+ for (const e of entries) {
64
+ const p = path.join(tmp, e.name);
65
+ const st = await fs.stat(p).catch(() => null);
66
+ if (!st) continue;
67
+ if (now - st.mtimeMs > maxAge) {
68
+ actions.push({ action: 'remove_tmp', path: p, bytes: e.isDirectory() ? await dirSize(p).catch(() => 0) : st.size });
69
+ if (!dryRun) await rmrf(p);
70
+ }
71
+ }
72
+ }
73
+
74
+ async function pruneOldMissions(root, policy, dryRun, actions) {
75
+ const missions = await listMissionDirs(root);
76
+ const now = Date.now();
77
+ const maxAge = policy.max_mission_age_days * 24 * 60 * 60 * 1000;
78
+ for (let i = 0; i < missions.length; i++) {
79
+ const m = missions[i];
80
+ const tooMany = i >= policy.max_missions;
81
+ const tooOld = now - m.mtimeMs > maxAge;
82
+ if (tooMany || tooOld) {
83
+ actions.push({ action: 'remove_mission', mission: m.id, path: m.path, bytes: m.size, reason: tooMany ? 'max_missions' : 'max_age' });
84
+ if (!dryRun) await rmrf(m.path);
85
+ }
86
+ }
87
+ }
88
+
89
+ async function compactMission(mission, policy, dryRun, actions) {
90
+ if (mission.size <= policy.max_mission_bytes) return;
91
+ const ralph = path.join(mission.path, 'ralph');
92
+ if (await exists(ralph)) {
93
+ const entries = await fs.readdir(ralph, { withFileTypes: true }).catch(() => []);
94
+ const dirs = [];
95
+ for (const e of entries) {
96
+ if (!e.isDirectory() || !/^cycle-\d+$/.test(e.name)) continue;
97
+ const n = Number(e.name.replace('cycle-', ''));
98
+ const p = path.join(ralph, e.name);
99
+ dirs.push({ n, path: p, bytes: await dirSize(p).catch(() => 0) });
100
+ }
101
+ dirs.sort((a, b) => b.n - a.n);
102
+ for (const d of dirs.slice(policy.keep_last_cycles_per_mission)) {
103
+ actions.push({ action: 'remove_old_cycle_dir', mission: mission.id, path: d.path, bytes: d.bytes });
104
+ if (!dryRun) await rmrf(d.path);
105
+ }
106
+ }
107
+ const arena = path.join(mission.path, 'arenas');
108
+ if (await exists(arena)) {
109
+ const bytes = await dirSize(arena).catch(() => 0);
110
+ if (bytes > 0) {
111
+ actions.push({ action: 'remove_mission_arenas', mission: mission.id, path: arena, bytes });
112
+ if (!dryRun) await rmrf(arena);
113
+ }
114
+ }
115
+ }
116
+
117
+ async function rotateLargeJsonl(root, policy, dryRun, actions) {
118
+ const files = await listFilesRecursive(path.join(root, '.sneakoscope'), { maxFiles: 100000 }).catch(() => []);
119
+ for (const f of files) {
120
+ if (!f.endsWith('.jsonl')) continue;
121
+ const size = await fileSize(f);
122
+ if (size <= policy.max_event_log_bytes) continue;
123
+ actions.push({ action: 'rotate_jsonl', path: f, bytes: size, keep_bytes: Math.floor(policy.max_event_log_bytes / 2) });
124
+ if (!dryRun) await appendJsonlBounded(f, { ts: nowIso(), type: 'gc.rotate_requested' }, policy.max_event_log_bytes);
125
+ }
126
+ }
127
+
128
+ export async function enforceRetention(root, opts = {}) {
129
+ const policy = { ...(await loadRetentionPolicy(root)), ...(opts.policy || {}) };
130
+ const dryRun = Boolean(opts.dryRun);
131
+ const actions = [];
132
+ await ensureDir(path.join(root, '.sneakoscope', 'reports'));
133
+ await pruneTmp(root, policy, dryRun, actions);
134
+ await pruneOldMissions(root, policy, dryRun, actions);
135
+ for (const m of await listMissionDirs(root)) await compactMission(m, policy, dryRun, actions);
136
+ await rotateLargeJsonl(root, policy, dryRun, actions);
137
+ const report = await storageReport(root);
138
+ if (!dryRun) await writeJsonAtomic(path.join(root, '.sneakoscope', 'reports', 'storage.json'), report);
139
+ return { dryRun, policy, actions, report };
140
+ }
@@ -0,0 +1,19 @@
1
+ import path from 'node:path';
2
+ import { exists, packageRoot, runProcess, which } from './fsx.mjs';
3
+
4
+ export async function findRustAccelerator() {
5
+ const env = process.env.SKS_RS_BIN || process.env.DCODEX_RS_BIN;
6
+ if (env && await exists(env)) return env;
7
+ const global = await which(process.platform === 'win32' ? 'sks-rs.exe' : 'sks-rs');
8
+ if (global) return global;
9
+ const candidate = path.join(packageRoot(), 'crates', 'sks-core', 'target', 'release', process.platform === 'win32' ? 'sks-rs.exe' : 'sks-rs');
10
+ if (await exists(candidate)) return candidate;
11
+ return null;
12
+ }
13
+
14
+ export async function rustInfo() {
15
+ const bin = await findRustAccelerator();
16
+ if (!bin) return { available: false };
17
+ const result = await runProcess(bin, ['--version'], { timeoutMs: 3000, maxOutputBytes: 20_000 });
18
+ return { available: result.code === 0, bin, version: `${result.stdout}${result.stderr}`.trim() };
19
+ }
@@ -0,0 +1,68 @@
1
+ const TAU = 2 * Math.PI;
2
+
3
+ export function clamp01(x) { return Math.max(0, Math.min(1, Number.isFinite(x) ? x : 0)); }
4
+ export function wave(theta, phi) { return 0.5 + 0.5 * Math.cos(theta - phi); }
5
+
6
+ export function trigScore(missionCoord = {}, claimCoord = {}) {
7
+ const domainDelta = (missionCoord.domainAngle || 0) - (claimCoord.domainAngle || 0);
8
+ const layerDelta = (missionCoord.layerRadius || 0) - (claimCoord.layerRadius || 0);
9
+ const phaseDelta = (missionCoord.phase || 0) - (claimCoord.phase || 0);
10
+ return (
11
+ 0.45 * wave(domainDelta, 0) +
12
+ 0.25 * wave(layerDelta * 0.7, 0) +
13
+ 0.30 * wave(phaseDelta, 0)
14
+ );
15
+ }
16
+
17
+ export function claimScore(mission, claim) {
18
+ const support = { supported: 1, weak: 0.55, unknown: 0.2, unsupported: -1, conflicted: -2, stale: 0.05 }[claim.status || 'unknown'] ?? 0;
19
+ const authority = { code: 1, test: 0.95, contract: 0.9, vgraph: 0.8, beta: 0.7, wiki: 0.55, visual_parse: 0.45, model: -0.5 }[claim.authority || 'wiki'] ?? 0.5;
20
+ const risk = { low: 0.1, medium: 0.35, high: 0.75, critical: 1 }[claim.risk || 'medium'] ?? 0.35;
21
+ const freshness = { fresh: 1, unknown: 0.35, stale: -0.6 }[claim.freshness || 'unknown'] ?? 0.35;
22
+ const tokenCost = Math.max(1, claim.tokenCost || String(claim.text || '').length / 4);
23
+ const r = Number.isFinite(claim.concentration) ? claim.concentration : 0.75;
24
+ const normCompensation = (1 - clamp01(r)) * Math.log1p(claim.evidence_count || 0) * 0.12;
25
+ return trigScore(mission.coord, claim.coord) + support + authority + 0.3 * risk + 0.4 * freshness + normCompensation - 0.01 * tokenCost;
26
+ }
27
+
28
+ function topKByScore(items, k) {
29
+ if (k <= 0) return [];
30
+ const top = [];
31
+ for (const item of items) {
32
+ if (top.length < k) {
33
+ top.push(item);
34
+ if (top.length === k) top.sort((a, b) => a.score - b.score);
35
+ continue;
36
+ }
37
+ if (item.score > top[0].score) {
38
+ top[0] = item;
39
+ top.sort((a, b) => a.score - b.score);
40
+ }
41
+ }
42
+ return top.sort((a, b) => b.score - a.score);
43
+ }
44
+
45
+ export function selectClaims(mission, claims, budget = {}) {
46
+ const maxClaims = Math.max(0, budget.maxClaims ?? 12);
47
+ const scored = (claims || []).map((claim) => ({ claim, score: claimScore(mission, claim) }));
48
+ return topKByScore(scored, maxClaims).map((x) => ({ ...x.claim, triwiki_score: Number(x.score.toFixed(4)) }));
49
+ }
50
+
51
+ export function geometricOffsets(max = 65536) {
52
+ const out = [];
53
+ for (let x = 1; x <= max; x *= 2) out.push(x);
54
+ return out;
55
+ }
56
+
57
+ export function contextCapsule({ mission, role = 'worker', contractHash = null, claims = [], q4 = {}, q3 = [] }) {
58
+ const selected = selectClaims(mission, claims, { maxClaims: role.includes('verifier') ? 16 : 10 });
59
+ return {
60
+ mission: mission.id,
61
+ role,
62
+ contract_hash: contractHash,
63
+ token_policy: 'Q4_Q3_DEFAULT_Q2_ON_DEMAND_Q1_FOR_VERIFICATION_ONLY',
64
+ q4,
65
+ q3,
66
+ claims: selected.map((c) => ({ id: c.id, text: c.text, status: c.status, risk: c.risk, source: c.source, score: c.triwiki_score }))
67
+ };
68
+ }