Docs/Skills and code/src/
src/topic-research/util.js
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { callLLM, parseJson } from "../llm.js";
export const slugify = s => String(s).toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
export const sha = s => crypto.createHash("sha1").update(s).digest("hex").slice(0, 16);
// Letters/digits only, so "Moser Baer", "Moserbaer" and "MOSER-BAER" compare equal.
export const squash = s => String(s ?? "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
// Same but keeps word gaps; used for quote matching.
export const norm = s => String(s ?? "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
export function readJson(file, fallback = null) {
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return fallback; }
}
export function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(value, null, 2));
}
export function stamp(seconds) {
const t = Math.max(0, Math.floor(seconds || 0));
const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60;
const p = n => String(n).padStart(2, "0");
return h ? `${h}:${p(m)}:${p(s)}` : `${p(m)}:${p(s)}`;
}
/** "mm:ss" or "h:mm:ss" -> seconds, or null. */
export function parseStamp(text) {
const m = String(text ?? "").match(/(\d+):(\d{2})(?::(\d{2}))?/);
if (!m) return null;
return m[3] != null ? (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]) : (+m[1]) * 60 + (+m[2]);
}
// The model sometimes wraps the single requested object in a one-element array.
const unwrap = r => (Array.isArray(r) && r.length === 1 && r[0] && typeof r[0] === "object" ? r[0] : r);
/**
* Asks the LLM for a JSON object. Every answer is cached on disk by prompt hash, so re-running a
* topic reproduces the same research without new LLM calls (and without their run-to-run variance).
*/
export async function askJson(prompt, { cacheDir, label = "llm" } = {}) {
const file = cacheDir ? path.join(cacheDir, `${sha(prompt)}.json`) : null;
const hit = file && readJson(file);
if (hit) return unwrap(hit.result);
let lastError;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await callLLM({ user: prompt, stream: false });
const result = parseJson(response.content);
if (file) writeJson(file, { label, model: response.model, provider: response.provider, result });
return unwrap(result);
} catch (error) {
lastError = error;
console.warn(`[${label}] attempt ${attempt + 1} failed: ${String(error.message).split("\n")[0].slice(0, 200)}`);
}
}
throw lastError;
}
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.