Docs/Skills and code/src/

src/topic-research/util.js

Code62 lines2.6 KBsrc/

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.