#!/usr/bin/env npx tsx
/**
* snappy-mine/api.ts -- Content mining operations for all snappy-* skills.
*
* Usage:
* npx tsx api.ts manifest # show manifest.json
* npx tsx api.ts sources # list all mined source files
* npx tsx api.ts files # list framework-mine-*.json files
* npx tsx api.ts persist deep-ray-sessions.json # persist nuggets to content engine DB
*
* Or import as module:
* import { getMineFiles, getManifest, persistNuggets } from "../snappy-mine/api.ts";
*/
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "fs";
import { join } from "path";
import { execSync } from "child_process";
import { env } from "../snappy-settings/load.ts";
import { boundRows, limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
import { refusalTable, refuseCli } from "../snappy-settings/refusal-codes.ts";
const MINED_DIR = join(process.env.HOME!, ".claude/corpus/mined");
const MANIFEST_PATH = join(MINED_DIR, "manifest.json");
const KRISP_DIR = join(process.env.HOME!, ".claude/corpus/krisp");
const KRISP_INDEX = join(KRISP_DIR, "index.json");
const SPEAKER_MAP_PATH = join(process.env.HOME!, ".claude/skills/snappy-mine/speaker-map.json");
// ---------------------------------------------------------------------------
// meetingsByParticipant
// ---------------------------------------------------------------------------
export interface MeetingMatch {
meeting_id: string;
date: string;
title: string;
participants: string[];
transcript_path: string;
}
interface SpeakerMapEntry {
full_name?: string;
aliases?: string[];
email?: string;
linkedin?: string | null;
xano_contact_id?: number | null;
}
function loadSpeakerMap(): Record<string, SpeakerMapEntry> {
if (!existsSync(SPEAKER_MAP_PATH)) return {};
try {
return JSON.parse(readFileSync(SPEAKER_MAP_PATH, "utf-8"));
} catch {
return {};
}
}
/**
* Parse frontmatter of a Krisp transcript + scrape speaker lines (`**Name | mm:ss**`).
* Returns the union of frontmatter attendees and in-transcript speakers.
* Reads only the first ~16KB of the file — enough for frontmatter + dozens of speaker lines.
*/
function readMeetingMeta(absPath: string): {
meeting_id: string;
date: string;
title: string;
participants: string[];
} | null {
let raw: string;
try {
const fd = readFileSync(absPath, "utf-8");
raw = fd.slice(0, 16_000);
} catch {
return null;
}
let meeting_id = "";
let date = "";
let title = "";
const fmAttendees: string[] = [];
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) {
const fm = fmMatch[1];
const idM = fm.match(/meeting_id:\s*(\S+)/);
if (idM) meeting_id = idM[1];
const dateM = fm.match(/date:\s*(\S+)/);
if (dateM) date = dateM[1];
const nameM = fm.match(/name:\s*"?([^"\n]+?)"?\s*$/m);
if (nameM) title = nameM[1].trim();
const attM = fm.match(/attendees:\s*\[([^\]]*)\]/);
if (attM && attM[1].trim()) {
for (const piece of attM[1].split(",")) {
const cleaned = piece.trim().replace(/^["']|["']$/g, "");
if (cleaned) fmAttendees.push(cleaned);
}
}
}
// Scrape speaker lines: **Name | 04:18**
const speakerSet = new Set<string>(fmAttendees);
const speakerRe = /\*\*([^*|]+?)\s*\|\s*\d{1,2}:\d{2}\*\*/g;
let m: RegExpExecArray | null;
while ((m = speakerRe.exec(raw)) !== null) {
const name = m[1].trim();
if (name && !/^speaker\s*\d+$/i.test(name)) speakerSet.add(name);
}
return {
meeting_id,
date,
title,
participants: Array.from(speakerSet),
};
}
/**
* Find Krisp meetings where a given participant appears.
* Matching precedence:
* 1. exact email (via speaker-map lookup)
* 2. exact name (case-insensitive)
* 3. fuzzy name (substring, case-insensitive)
*
* Returns MeetingMatch[] sorted by date desc. Capped at 50 results.
* Source of truth is the Krisp index.json — we only open transcript files
* whose filename already hints at a name match, then verify via scraped participants.
*
* NOTE: `recent_meetings` in snappy-knowledge.resolvePerson is currently []
* because this function did not exist. This is its backing store.
*/
export function meetingsByParticipant(
handle: string,
opts: { limit?: number } = {}
): MeetingMatch[] {
if (!existsSync(KRISP_INDEX)) return [];
const limit = opts.limit ?? 50;
const speakerMap = loadSpeakerMap();
// Resolve handle → set of candidate names to match against
const candidateNames = new Set<string>();
const isEmail = handle.includes("@");
if (isEmail) {
for (const [key, entry] of Object.entries(speakerMap)) {
if (entry?.email?.toLowerCase() === handle.toLowerCase()) {
candidateNames.add(key.toLowerCase());
if (entry.full_name) candidateNames.add(entry.full_name.toLowerCase());
for (const a of entry.aliases ?? []) candidateNames.add(a.toLowerCase());
}
}
if (candidateNames.size === 0) return []; // no email → can't match transcripts
} else {
candidateNames.add(handle.toLowerCase());
// Pull aliases from speaker map if this key exists
for (const [key, entry] of Object.entries(speakerMap)) {
const allNames = [key, entry?.full_name, ...(entry?.aliases ?? [])]
.filter(Boolean)
.map((s) => (s as string).toLowerCase());
if (allNames.includes(handle.toLowerCase())) {
for (const n of allNames) candidateNames.add(n);
}
}
}
const index: Record<string, string> = JSON.parse(readFileSync(KRISP_INDEX, "utf-8"));
const results: MeetingMatch[] = [];
// First pass: filename substring filter (cheap) against any candidate name token
const nameTokens = new Set<string>();
for (const n of candidateNames) {
for (const tok of n.split(/\s+/)) {
if (tok.length >= 3) nameTokens.add(tok);
}
}
for (const [meeting_id, relPath] of Object.entries(index)) {
const lowerPath = relPath.toLowerCase();
const filenameHit = Array.from(nameTokens).some((tok) => lowerPath.includes(tok));
// Always open the file if filename hits; otherwise skip (too expensive to scan all)
if (!filenameHit) continue;
const absPath = join(KRISP_DIR, relPath);
if (!existsSync(absPath)) continue;
const meta = readMeetingMeta(absPath);
if (!meta) continue;
// Verify: does any participant match a candidate name (exact or fuzzy)?
const participantsLower = meta.participants.map((p) => p.toLowerCase());
let matched = false;
for (const cand of candidateNames) {
if (participantsLower.includes(cand)) { matched = true; break; } // exact
}
if (!matched) {
for (const cand of candidateNames) {
if (participantsLower.some((p) => p.includes(cand) || cand.includes(p))) {
matched = true; break;
}
}
}
if (!matched) continue;
results.push({
meeting_id: meta.meeting_id || meeting_id,
date: meta.date,
title: meta.title || relPath,
participants: meta.participants,
transcript_path: absPath,
});
}
results.sort((a, b) => (b.date || "").localeCompare(a.date || ""));
return results.slice(0, limit);
}
/** Walk ~/.claude/corpus/krisp/YYYY/MM/*.md and return raw transcript paths. */
export function listAllTranscripts(): string[] {
const out: string[] = [];
if (!existsSync(KRISP_DIR)) return out;
for (const year of readdirSync(KRISP_DIR)) {
const yearDir = join(KRISP_DIR, year);
let stat;
try { stat = statSync(yearDir); } catch { continue; }
if (!stat.isDirectory()) continue;
for (const month of readdirSync(yearDir)) {
const monthDir = join(yearDir, month);
try { if (!statSync(monthDir).isDirectory()) continue; } catch { continue; }
for (const f of readdirSync(monthDir)) {
if (f.endsWith(".md") && !f.endsWith(".summary.md") && !f.endsWith(".nuggets.json")) {
out.push(join(monthDir, f));
}
}
}
}
return out;
}
/** Returns transcripts that are NOT yet referenced in mined/manifest.json. */
export function listPendingTranscripts(opts: { limit?: number } = {}): string[] {
const all = listAllTranscripts();
const manifest = getManifest();
const minedNames = new Set<string>();
for (const [key, entry] of Object.entries(manifest)) {
if (key === "_skipped") {
for (const s of (entry as any[]) || []) minedNames.add(typeof s === "string" ? s : s.file);
continue;
}
for (const s of (entry as any)?.source_files || []) minedNames.add(s);
}
const pending = all
.filter((p) => !minedNames.has(p.split("/").pop() || ""))
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
return opts.limit ? pending.slice(0, opts.limit) : pending;
}
/** Lists framework-mine-*.json files in the mined directory. */
export function getMineFiles(): string[] {
if (!existsSync(MINED_DIR)) return [];
return readdirSync(MINED_DIR)
.filter((f) => f.startsWith("framework-mine-") && f.endsWith(".json"))
.sort();
}
/** Reads and returns the manifest.json contents. */
export function getManifest(): Record<string, unknown> {
if (!existsSync(MANIFEST_PATH)) return {};
return JSON.parse(readFileSync(MANIFEST_PATH, "utf-8"));
}
/** Extracts all mined source files from the manifest. */
export function getMinedSources(): string[] {
const manifest = getManifest();
const sources: string[] = [];
const sf = manifest.source_files;
if (Array.isArray(sf)) return sf;
if (sf && typeof sf === "object") {
for (const v of Object.values(sf as Record<string, unknown>)) {
if (Array.isArray(v)) sources.push(...v);
else if (typeof v === "string") sources.push(v);
}
}
return sources;
}
/** Persists nuggets from a JSON file to the content engine DB via persist-nuggets.ts. */
export function persistNuggets(jsonPath: string): string {
const absPath = jsonPath.startsWith("/") ? jsonPath : join(MINED_DIR, jsonPath);
if (!existsSync(absPath)) throw new Error(`File not found: ${absPath}`);
const script = join(process.env.HOME!, ".claude/skills/snappy-mine/persist-nuggets.ts");
return execSync(`npx tsx ${script} ${absPath}`, { encoding: "utf-8", timeout: 60_000 });
}
// --- Metrics (Step 7a) ---
const STAGED_ACTIONS_LOG = join(process.env.HOME!, ".claude/logs/staged-actions.ndjson");
type StagedRun = { ts: string; name: string; action: string };
function readStagedRunsMine(): StagedRun[] {
if (!existsSync(STAGED_ACTIONS_LOG)) return [];
const out: StagedRun[] = [];
for (const line of readFileSync(STAGED_ACTIONS_LOG, "utf-8").split("\n")) {
if (!line.trim()) continue;
try {
const j = JSON.parse(line);
if (typeof j?.name === "string" && typeof j?.ts === "string") {
out.push({ ts: j.ts, name: j.name, action: j.action || "" });
}
} catch { /* skip */ }
}
return out;
}
function withinLastDays(tsIso: string, days: number): boolean {
const t = new Date(tsIso).getTime();
if (isNaN(t)) return false;
return t >= Date.now() - days * 86400_000;
}
export function computeMineMetric(name: string): number | null {
const runs = readStagedRunsMine().filter((r) => withinLastDays(r.ts, 7));
switch (name) {
case "runs-per-week":
case "mine_runs_per_week":
return runs.filter((r) => r.name === "content-mine").length;
case "success-rate":
case "mine_success_rate": {
const mine = runs.filter((r) => r.name === "content-mine");
if (!mine.length) return null;
return mine.filter((r) => r.action !== "error").length / mine.length;
}
default:
return null;
}
}
// --- CLI ---
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
export const HAND_CONTRACT = {
skill: "snappy-mine",
description: "Snappy mine extracts technical content from the Krisp corpus: frameworks, tool tutorials, and synthesized patterns -- NOT stories, NOT quotes, NOT observations about conversations.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "not_found"),
verbs: {
files: {
args: [], flags: { limit: "--limit" }, effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: {
limit: limitSchema(200, "How many files to return"),
} },
},
manifest: {
args: [], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
"meetings-by": {
args: ["limit?"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { limit: { type: "integer", description: "How many rows to return", default: 20, maximum: 200 } } },
},
metrics: {
args: ["name"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { name: { type: "string", description: "Name of the mine metric to compute" } } },
},
pending: {
args: ["limit?"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { limit: { type: "integer", description: "How many rows to return", default: 20, maximum: 200 } } },
},
persist: {
args: ["json-file"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { "json-file": { type: "string", description: "JSON file whose rows are written into the mine" } } },
},
sources: {
args: [], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "metrics": {
const [name, ...rest] = args;
if (!name) {
console.error("Usage: api.ts metrics <name> [--json]");
console.error("Names: runs-per-week, success-rate");
process.exit(1);
}
const value = computeMineMetric(name);
if (rest.includes("--json")) console.log(JSON.stringify({ value }));
else console.log(value == null ? "null" : String(value));
break;
}
case "files": {
const bound = takeLimit(args, { maximum: 200 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exit(1); }
const all = getMineFiles();
const files = boundRows(all, bound.limit);
console.log(`${files.length} of ${all.length} framework-mine files:`);
for (const f of files) console.log(` ${f}`);
break;
}
// AN ABSENT CORPUS IS NOT AN EMPTY ONE ⟨lane refusals-2, 2026-09-09⟩.
// MEASURED on this Mac before the change: neither ~/.claude/corpus/mined
// nor ~/.claude/corpus/krisp exists, and `manifest` printed `{}`,
// `sources` printed "0 mined sources:" and `pending` printed "0 unmined
// transcripts:" — each EXIT 0. A caller could not tell "nothing has been
// mined yet" from "there is no corpus on this machine", which is a
// refusal reported as an acceptance: worse than an error, because an
// error ends the wait. The counts are honest only once the root exists.
case "manifest": {
if (!existsSync(MINED_DIR)) { refuseCli("not_found", `There is no mined corpus on this machine: ${MINED_DIR.replace(process.env.HOME!, "~")} does not exist, so "{}" here would mean "no corpus", not "nothing mined". Run a mine pass on the Mac that holds the corpus.`); break; }
const m = getManifest();
console.log(JSON.stringify(m, null, 2));
break;
}
case "sources": {
if (!existsSync(MINED_DIR)) { refuseCli("not_found", `There is no mined corpus on this machine: ${MINED_DIR.replace(process.env.HOME!, "~")} does not exist, so a count of 0 here would mean "no corpus", not "nothing mined".`); break; }
const sources = getMinedSources();
console.log(`${sources.length} mined sources:`);
for (const s of sources) console.log(` ${s}`);
break;
}
case "pending": {
// `pending` reads BOTH roots: the transcripts (krisp) and what has
// already been mined. The transcripts are the one whose absence turns
// every answer into a silent zero.
if (!existsSync(KRISP_DIR)) { refuseCli("not_found", `There are no transcripts on this machine: ${KRISP_DIR.replace(process.env.HOME!, "~")} does not exist, so "0 unmined transcripts" here would mean "no corpus", not "all mined".`); break; }
const limit = args[0] ? parseInt(args[0], 10) : undefined;
const pending = listPendingTranscripts(limit ? { limit } : {});
if (args.includes("--json")) { console.log(JSON.stringify(pending, null, 2)); break; }
console.log(`${pending.length} unmined transcripts:`);
for (const p of pending) console.log(` ${p.replace(process.env.HOME!, "~")}`);
break;
}
case "meetings-by": {
const [handle, limitStr] = args;
if (!handle) { console.error("Usage: api.ts meetings-by <name|email> [limit]"); process.exit(1); }
const limit = limitStr ? parseInt(limitStr, 10) : 20;
const results = meetingsByParticipant(handle, { limit });
console.log(JSON.stringify(results, null, 2));
break;
}
case "persist": {
const [path] = args;
if (!path) { console.error("Usage: api.ts persist <json-file>"); process.exit(1); }
const output = persistNuggets(path);
console.log(output);
break;
}
default:
console.log("Usage: npx tsx api.ts [files|manifest|sources|persist] ...");
}
})();
}
#!/usr/bin/env npx tsx
/**
* snappy-mine/api.ts -- Content mining operations for all snappy-* skills.
*
* Usage:
* npx tsx api.ts manifest # show manifest.json
* npx tsx api.ts sources # list all mined source files
* npx tsx api.ts files # list framework-mine-*.json files
* npx tsx api.ts persist deep-ray-sessions.json # persist nuggets to content engine DB
*
* Or import as module:
* import { getMineFiles, getManifest, persistNuggets } from "../snappy-mine/api.ts";
*/
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "fs";
import { join } from "path";
import { execSync } from "child_process";
import { env } from "../snappy-settings/load.ts";
import { boundRows, limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
import { refusalTable, refuseCli } from "../snappy-settings/refusal-codes.ts";
const MINED_DIR = join(process.env.HOME!, ".claude/corpus/mined");
const MANIFEST_PATH = join(MINED_DIR, "manifest.json");
const KRISP_DIR = join(process.env.HOME!, ".claude/corpus/krisp");
const KRISP_INDEX = join(KRISP_DIR, "index.json");
const SPEAKER_MAP_PATH = join(process.env.HOME!, ".claude/skills/snappy-mine/speaker-map.json");
// ---------------------------------------------------------------------------
// meetingsByParticipant
// ---------------------------------------------------------------------------
export interface MeetingMatch {
meeting_id: string;
date: string;
title: string;
participants: string[];
transcript_path: string;
}
interface SpeakerMapEntry {
full_name?: string;
aliases?: string[];
email?: string;
linkedin?: string | null;
xano_contact_id?: number | null;
}
function loadSpeakerMap(): Record<string, SpeakerMapEntry> {
if (!existsSync(SPEAKER_MAP_PATH)) return {};
try {
return JSON.parse(readFileSync(SPEAKER_MAP_PATH, "utf-8"));
} catch {
return {};
}
}
/**
* Parse frontmatter of a Krisp transcript + scrape speaker lines (`**Name | mm:ss**`).
* Returns the union of frontmatter attendees and in-transcript speakers.
* Reads only the first ~16KB of the file — enough for frontmatter + dozens of speaker lines.
*/
function readMeetingMeta(absPath: string): {
meeting_id: string;
date: string;
title: string;
participants: string[];
} | null {
let raw: string;
try {
const fd = readFileSync(absPath, "utf-8");
raw = fd.slice(0, 16_000);
} catch {
return null;
}
let meeting_id = "";
let date = "";
let title = "";
const fmAttendees: string[] = [];
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) {
const fm = fmMatch[1];
const idM = fm.match(/meeting_id:\s*(\S+)/);
if (idM) meeting_id = idM[1];
const dateM = fm.match(/date:\s*(\S+)/);
if (dateM) date = dateM[1];
const nameM = fm.match(/name:\s*"?([^"\n]+?)"?\s*$/m);
if (nameM) title = nameM[1].trim();
const attM = fm.match(/attendees:\s*\[([^\]]*)\]/);
if (attM && attM[1].trim()) {
for (const piece of attM[1].split(",")) {
const cleaned = piece.trim().replace(/^["']|["']$/g, "");
if (cleaned) fmAttendees.push(cleaned);
}
}
}
// Scrape speaker lines: **Name | 04:18**
const speakerSet = new Set<string>(fmAttendees);
const speakerRe = /\*\*([^*|]+?)\s*\|\s*\d{1,2}:\d{2}\*\*/g;
let m: RegExpExecArray | null;
while ((m = speakerRe.exec(raw)) !== null) {
const name = m[1].trim();
if (name && !/^speaker\s*\d+$/i.test(name)) speakerSet.add(name);
}
return {
meeting_id,
date,
title,
participants: Array.from(speakerSet),
};
}
/**
* Find Krisp meetings where a given participant appears.
* Matching precedence:
* 1. exact email (via speaker-map lookup)
* 2. exact name (case-insensitive)
* 3. fuzzy name (substring, case-insensitive)
*
* Returns MeetingMatch[] sorted by date desc. Capped at 50 results.
* Source of truth is the Krisp index.json — we only open transcript files
* whose filename already hints at a name match, then verify via scraped participants.
*
* NOTE: `recent_meetings` in snappy-knowledge.resolvePerson is currently []
* because this function did not exist. This is its backing store.
*/
export function meetingsByParticipant(
handle: string,
opts: { limit?: number } = {}
): MeetingMatch[] {
if (!existsSync(KRISP_INDEX)) return [];
const limit = opts.limit ?? 50;
const speakerMap = loadSpeakerMap();
// Resolve handle → set of candidate names to match against
const candidateNames = new Set<string>();
const isEmail = handle.includes("@");
if (isEmail) {
for (const [key, entry] of Object.entries(speakerMap)) {
if (entry?.email?.toLowerCase() === handle.toLowerCase()) {
candidateNames.add(key.toLowerCase());
if (entry.full_name) candidateNames.add(entry.full_name.toLowerCase());
for (const a of entry.aliases ?? []) candidateNames.add(a.toLowerCase());
}
}
if (candidateNames.size === 0) return []; // no email → can't match transcripts
} else {
candidateNames.add(handle.toLowerCase());
// Pull aliases from speaker map if this key exists
for (const [key, entry] of Object.entries(speakerMap)) {
const allNames = [key, entry?.full_name, ...(entry?.aliases ?? [])]
.filter(Boolean)
.map((s) => (s as string).toLowerCase());
if (allNames.includes(handle.toLowerCase())) {
for (const n of allNames) candidateNames.add(n);
}
}
}
const index: Record<string, string> = JSON.parse(readFileSync(KRISP_INDEX, "utf-8"));
const results: MeetingMatch[] = [];
// First pass: filename substring filter (cheap) against any candidate name token
const nameTokens = new Set<string>();
for (const n of candidateNames) {
for (const tok of n.split(/\s+/)) {
if (tok.length >= 3) nameTokens.add(tok);
}
}
for (const [meeting_id, relPath] of Object.entries(index)) {
const lowerPath = relPath.toLowerCase();
const filenameHit = Array.from(nameTokens).some((tok) => lowerPath.includes(tok));
// Always open the file if filename hits; otherwise skip (too expensive to scan all)
if (!filenameHit) continue;
const absPath = join(KRISP_DIR, relPath);
if (!existsSync(absPath)) continue;
const meta = readMeetingMeta(absPath);
if (!meta) continue;
// Verify: does any participant match a candidate name (exact or fuzzy)?
const participantsLower = meta.participants.map((p) => p.toLowerCase());
let matched = false;
for (const cand of candidateNames) {
if (participantsLower.includes(cand)) { matched = true; break; } // exact
}
if (!matched) {
for (const cand of candidateNames) {
if (participantsLower.some((p) => p.includes(cand) || cand.includes(p))) {
matched = true; break;
}
}
}
if (!matched) continue;
results.push({
meeting_id: meta.meeting_id || meeting_id,
date: meta.date,
title: meta.title || relPath,
participants: meta.participants,
transcript_path: absPath,
});
}
results.sort((a, b) => (b.date || "").localeCompare(a.date || ""));
return results.slice(0, limit);
}
/** Walk ~/.claude/corpus/krisp/YYYY/MM/*.md and return raw transcript paths. */
export function listAllTranscripts(): string[] {
const out: string[] = [];
if (!existsSync(KRISP_DIR)) return out;
for (const year of readdirSync(KRISP_DIR)) {
const yearDir = join(KRISP_DIR, year);
let stat;
try { stat = statSync(yearDir); } catch { continue; }
if (!stat.isDirectory()) continue;
for (const month of readdirSync(yearDir)) {
const monthDir = join(yearDir, month);
try { if (!statSync(monthDir).isDirectory()) continue; } catch { continue; }
for (const f of readdirSync(monthDir)) {
if (f.endsWith(".md") && !f.endsWith(".summary.md") && !f.endsWith(".nuggets.json")) {
out.push(join(monthDir, f));
}
}
}
}
return out;
}
/** Returns transcripts that are NOT yet referenced in mined/manifest.json. */
export function listPendingTranscripts(opts: { limit?: number } = {}): string[] {
const all = listAllTranscripts();
const manifest = getManifest();
const minedNames = new Set<string>();
for (const [key, entry] of Object.entries(manifest)) {
if (key === "_skipped") {
for (const s of (entry as any[]) || []) minedNames.add(typeof s === "string" ? s : s.file);
continue;
}
for (const s of (entry as any)?.source_files || []) minedNames.add(s);
}
const pending = all
.filter((p) => !minedNames.has(p.split("/").pop() || ""))
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
return opts.limit ? pending.slice(0, opts.limit) : pending;
}
/** Lists framework-mine-*.json files in the mined directory. */
export function getMineFiles(): string[] {
if (!existsSync(MINED_DIR)) return [];
return readdirSync(MINED_DIR)
.filter((f) => f.startsWith("framework-mine-") && f.endsWith(".json"))
.sort();
}
/** Reads and returns the manifest.json contents. */
export function getManifest(): Record<string, unknown> {
if (!existsSync(MANIFEST_PATH)) return {};
return JSON.parse(readFileSync(MANIFEST_PATH, "utf-8"));
}
/** Extracts all mined source files from the manifest. */
export function getMinedSources(): string[] {
const manifest = getManifest();
const sources: string[] = [];
const sf = manifest.source_files;
if (Array.isArray(sf)) return sf;
if (sf && typeof sf === "object") {
for (const v of Object.values(sf as Record<string, unknown>)) {
if (Array.isArray(v)) sources.push(...v);
else if (typeof v === "string") sources.push(v);
}
}
return sources;
}
/** Persists nuggets from a JSON file to the content engine DB via persist-nuggets.ts. */
export function persistNuggets(jsonPath: string): string {
const absPath = jsonPath.startsWith("/") ? jsonPath : join(MINED_DIR, jsonPath);
if (!existsSync(absPath)) throw new Error(`File not found: ${absPath}`);
const script = join(process.env.HOME!, ".claude/skills/snappy-mine/persist-nuggets.ts");
return execSync(`npx tsx ${script} ${absPath}`, { encoding: "utf-8", timeout: 60_000 });
}
// --- Metrics (Step 7a) ---
const STAGED_ACTIONS_LOG = join(process.env.HOME!, ".claude/logs/staged-actions.ndjson");
type StagedRun = { ts: string; name: string; action: string };
function readStagedRunsMine(): StagedRun[] {
if (!existsSync(STAGED_ACTIONS_LOG)) return [];
const out: StagedRun[] = [];
for (const line of readFileSync(STAGED_ACTIONS_LOG, "utf-8").split("\n")) {
if (!line.trim()) continue;
try {
const j = JSON.parse(line);
if (typeof j?.name === "string" && typeof j?.ts === "string") {
out.push({ ts: j.ts, name: j.name, action: j.action || "" });
}
} catch { /* skip */ }
}
return out;
}
function withinLastDays(tsIso: string, days: number): boolean {
const t = new Date(tsIso).getTime();
if (isNaN(t)) return false;
return t >= Date.now() - days * 86400_000;
}
export function computeMineMetric(name: string): number | null {
const runs = readStagedRunsMine().filter((r) => withinLastDays(r.ts, 7));
switch (name) {
case "runs-per-week":
case "mine_runs_per_week":
return runs.filter((r) => r.name === "content-mine").length;
case "success-rate":
case "mine_success_rate": {
const mine = runs.filter((r) => r.name === "content-mine");
if (!mine.length) return null;
return mine.filter((r) => r.action !== "error").length / mine.length;
}
default:
return null;
}
}
// --- CLI ---
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
export const HAND_CONTRACT = {
skill: "snappy-mine",
description: "Snappy mine extracts technical content from the Krisp corpus: frameworks, tool tutorials, and synthesized patterns -- NOT stories, NOT quotes, NOT observations about conversations.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "not_found"),
verbs: {
files: {
args: [], flags: { limit: "--limit" }, effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: {
limit: limitSchema(200, "How many files to return"),
} },
},
manifest: {
args: [], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
"meetings-by": {
args: ["limit?"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { limit: { type: "integer", description: "How many rows to return", default: 20, maximum: 200 } } },
},
metrics: {
args: ["name"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { name: { type: "string", description: "Name of the mine metric to compute" } } },
},
pending: {
args: ["limit?"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { limit: { type: "integer", description: "How many rows to return", default: 20, maximum: 200 } } },
},
persist: {
args: ["json-file"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { "json-file": { type: "string", description: "JSON file whose rows are written into the mine" } } },
},
sources: {
args: [], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "metrics": {
const [name, ...rest] = args;
if (!name) {
console.error("Usage: api.ts metrics <name> [--json]");
console.error("Names: runs-per-week, success-rate");
process.exit(1);
}
const value = computeMineMetric(name);
if (rest.includes("--json")) console.log(JSON.stringify({ value }));
else console.log(value == null ? "null" : String(value));
break;
}
case "files": {
const bound = takeLimit(args, { maximum: 200 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exit(1); }
const all = getMineFiles();
const files = boundRows(all, bound.limit);
console.log(`${files.length} of ${all.length} framework-mine files:`);
for (const f of files) console.log(` ${f}`);
break;
}
// AN ABSENT CORPUS IS NOT AN EMPTY ONE ⟨lane refusals-2, 2026-09-09⟩.
// MEASURED on this Mac before the change: neither ~/.claude/corpus/mined
// nor ~/.claude/corpus/krisp exists, and `manifest` printed `{}`,
// `sources` printed "0 mined sources:" and `pending` printed "0 unmined
// transcripts:" — each EXIT 0. A caller could not tell "nothing has been
// mined yet" from "there is no corpus on this machine", which is a
// refusal reported as an acceptance: worse than an error, because an
// error ends the wait. The counts are honest only once the root exists.
case "manifest": {
if (!existsSync(MINED_DIR)) { refuseCli("not_found", `There is no mined corpus on this machine: ${MINED_DIR.replace(process.env.HOME!, "~")} does not exist, so "{}" here would mean "no corpus", not "nothing mined". Run a mine pass on the Mac that holds the corpus.`); break; }
const m = getManifest();
console.log(JSON.stringify(m, null, 2));
break;
}
case "sources": {
if (!existsSync(MINED_DIR)) { refuseCli("not_found", `There is no mined corpus on this machine: ${MINED_DIR.replace(process.env.HOME!, "~")} does not exist, so a count of 0 here would mean "no corpus", not "nothing mined".`); break; }
const sources = getMinedSources();
console.log(`${sources.length} mined sources:`);
for (const s of sources) console.log(` ${s}`);
break;
}
case "pending": {
// `pending` reads BOTH roots: the transcripts (krisp) and what has
// already been mined. The transcripts are the one whose absence turns
// every answer into a silent zero.
if (!existsSync(KRISP_DIR)) { refuseCli("not_found", `There are no transcripts on this machine: ${KRISP_DIR.replace(process.env.HOME!, "~")} does not exist, so "0 unmined transcripts" here would mean "no corpus", not "all mined".`); break; }
const limit = args[0] ? parseInt(args[0], 10) : undefined;
const pending = listPendingTranscripts(limit ? { limit } : {});
if (args.includes("--json")) { console.log(JSON.stringify(pending, null, 2)); break; }
console.log(`${pending.length} unmined transcripts:`);
for (const p of pending) console.log(` ${p.replace(process.env.HOME!, "~")}`);
break;
}
case "meetings-by": {
const [handle, limitStr] = args;
if (!handle) { console.error("Usage: api.ts meetings-by <name|email> [limit]"); process.exit(1); }
const limit = limitStr ? parseInt(limitStr, 10) : 20;
const results = meetingsByParticipant(handle, { limit });
console.log(JSON.stringify(results, null, 2));
break;
}
case "persist": {
const [path] = args;
if (!path) { console.error("Usage: api.ts persist <json-file>"); process.exit(1); }
const output = persistNuggets(path);
console.log(output);
break;
}
default:
console.log("Usage: npx tsx api.ts [files|manifest|sources|persist] ...");
}
})();
}