snappy-krisp skill
fetch-meetings after-date?readfetch-document meeting-idreadfetch-action-items limit?readmetrics nameread$ npx snappy-skills install snappy-krisp
$ npx snappy-skills install --all
$ npx snappy-skills update
<!-- SKILL-INDEX-START -->
[snappy-krisp Index]|root: ~/.claude/skills/snappy-krisp|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md}
<!-- SKILL-INDEX-END -->
Pure transformers that turn raw Krisp MCP payloads into structured work the
snappy-ops chassis can stage. No network calls — the agent fetches via
mcp__krisp__* tools, then pipes raw JSON through these helpers.
tsimport {
actionItemsFor, pendingActionItemsFor, actionItemsSince,
commitmentsFrom, extractToolMentions, meetingsSince,
} from "../snappy-krisp/api.ts";
// Claude Code agent fetches raw via MCP, then…
const mine = pendingActionItemsFor(raw); // items not-done, assigned to me
const recent = actionItemsSince(mine, "2026-04-01"); // last two weeks
const commits = commitmentsFrom(meetings); // from meeting_notes
const tools = extractToolMentions(keyPointsText); // scout list
| Helper | Purpose | Certificate |
|---|---|---|
pendingActionItemsFor(raw, who?) |
Filter list_action_items to my pending items | Returns array; caller verifies count > 0 before staging |
actionItemsSince(raw, iso) |
Filter by meeting_date ≥ iso | Returns array; caller logs staged run_id |
commitmentsFrom(raw, who?) |
Normalize meeting_notes.action_items | Returns Commitment[]; run_id written to staged-actions.ndjson |
extractToolMentions(text) |
Pull "try X" / URLs / foo.dev slugs |
Returns deduped string[]; certificate = the recipe's audit entry |
All side-effects happen in the calling recipe. This primitive has no state.
bashcat items.json | npx tsx api.ts pending-for "Robert"
cat items.json | npx tsx api.ts since 2026-04-01
cat meetings.json | npx tsx api.ts commitments "Robert"
cat meeting.json | npx tsx api.ts tool-mentions
Default assignee is "Robert Boulos". Override with KRISP_ME in
.env.cache. Matching is first-name tolerant so "Robert", "Robert B.", and
"Robert Boulos" all collapse to the same person.
Nothing in the collection names this skill.
<!-- SNAPPY-CONTRACT-VERBS-START -->
Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
fetch-meetings |
after-date? |
read |
npx tsx ~/.claude/skills/snappy-krisp/api.ts fetch-meetings |
fetch-document |
meeting-id |
read |
npx tsx ~/.claude/skills/snappy-krisp/api.ts fetch-document <meeting-id> |
fetch-action-items |
limit? |
read |
npx tsx ~/.claude/skills/snappy-krisp/api.ts fetch-action-items |
metrics |
name |
read |
npx tsx ~/.claude/skills/snappy-krisp/api.ts metrics "<name>" |
When an answer carries face_hint, show it with one snappy_present(<answer>) call.
See /snappy-faces for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
<!-- SKILL-INDEX-START -->
[snappy-krisp Index]|root: ~/.claude/skills/snappy-krisp|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md}
<!-- SKILL-INDEX-END -->
# snappy-krisp — Krisp → Ops Bridge
Pure transformers that turn raw Krisp MCP payloads into structured work the
snappy-ops chassis can stage. No network calls — the agent fetches via
`mcp__krisp__*` tools, then pipes raw JSON through these helpers.
## Purpose
- A snappy-ops recipe needs Robert's pending commitments from recent meetings
- You want to scout tool mentions from mastermind transcripts
- You need to bridge a specific meeting's action items into the work chassis
## When NOT to use
- You need to write/update Krisp data — this skill is read-only transform
- You need headless/cron access — see GAP in SKILL.md (no HTTP client yet)
## API shape
```ts
import {
actionItemsFor, pendingActionItemsFor, actionItemsSince,
commitmentsFrom, extractToolMentions, meetingsSince,
} from "../snappy-krisp/api.ts";
// Claude Code agent fetches raw via MCP, then…
const mine = pendingActionItemsFor(raw); // items not-done, assigned to me
const recent = actionItemsSince(mine, "2026-04-01"); // last two weeks
const commits = commitmentsFrom(meetings); // from meeting_notes
const tools = extractToolMentions(keyPointsText); // scout list
```
| Helper | Purpose | Certificate |
|---|---|---|
| `pendingActionItemsFor(raw, who?)` | Filter list_action_items to my pending items | Returns array; caller verifies count > 0 before staging |
| `actionItemsSince(raw, iso)` | Filter by meeting_date ≥ iso | Returns array; caller logs staged run_id |
| `commitmentsFrom(raw, who?)` | Normalize meeting_notes.action_items | Returns `Commitment[]`; run_id written to staged-actions.ndjson |
| `extractToolMentions(text)` | Pull "try X" / URLs / `foo.dev` slugs | Returns deduped string[]; certificate = the recipe's audit entry |
All side-effects happen in the calling recipe. This primitive has no state.
## CLI (stdin-piped)
```bash
cat items.json | npx tsx api.ts pending-for "Robert"
cat items.json | npx tsx api.ts since 2026-04-01
cat meetings.json | npx tsx api.ts commitments "Robert"
cat meeting.json | npx tsx api.ts tool-mentions
```
## Identity
Default assignee is `"Robert Boulos"`. Override with `KRISP_ME` in
`.env.cache`. Matching is first-name tolerant so "Robert", "Robert B.", and
"Robert Boulos" all collapse to the same person.
## Used by
Nothing in the collection names this skill.
<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs
Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `fetch-meetings` | `after-date?` | `read` | `npx tsx ~/.claude/skills/snappy-krisp/api.ts fetch-meetings` |
| `fetch-document` | `meeting-id` | `read` | `npx tsx ~/.claude/skills/snappy-krisp/api.ts fetch-document <meeting-id>` |
| `fetch-action-items` | `limit?` | `read` | `npx tsx ~/.claude/skills/snappy-krisp/api.ts fetch-action-items` |
| `metrics` | `name` | `read` | `npx tsx ~/.claude/skills/snappy-krisp/api.ts metrics "<name>"` |
## Show the result
When an answer carries `face_hint`, show it with one `snappy_present(<answer>)` call.
See `/snappy-faces` for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
Krisp is the inbound event stream of Robert's real-world leverage loop. Every
meeting produces signal — commitments, tool scouts, referral opportunities,
case study hooks — that today evaporates. This skill is the primitive that
turns that raw stream into staged work.
This skill is pure transformers. It does not call Krisp's API directly.
The auth lives inside the Claude Code MCP server (mcp__krisp__* tools), so
the calling agent is expected to fetch raw payloads via MCP and pipe them
through these helpers.
Headless/cron support (a proper HTTP client using an OAuth token pulled from
.env.cache) is a documented GAP. When we want cron-driven flows like
"Ray→ops bridge" or the "pre-call referral prompt" to run without a Claude
Code session attached, this primitive grows an HTTP layer. Until then,
recipes must be invoked from within a Claude Code context.
tsimport {
actionItemsFor, pendingActionItemsFor, actionItemsSince,
commitmentsFrom, extractToolMentions, meetingsSince, meetingsByNamePattern,
nameMatches,
type KrispActionItem, type KrispMeeting, type Commitment,
} from "../snappy-krisp/api.ts";
// Agent fetches raw via MCP…
const raw = await mcp__krisp__list_action_items({ limit: 50 });
// …then pipes it here.
const mine = pendingActionItemsFor(raw); // everything not-done assigned to me
const recent = actionItemsSince(mine, "2026-04-01"); // last two weeks
| Function | Input | Output |
|---|---|---|
actionItemsFor(raw, who?) |
MCP list_action_items response |
Items matching assignee (fuzzy, first-name tolerant) |
pendingActionItemsFor(raw, who?) |
same | Same, filtered to completed=false |
actionItemsSince(raw, iso) |
same | Items whose meeting_date ≥ iso |
commitmentsFrom(raw, who?) |
search_meetings response w/ notes |
Normalized commitments from meeting_notes.action_items |
extractToolMentions(text) |
free text (transcript, key_points) | Unique tool/URL/slug mentions |
meetingsSince(raw, iso) |
search_meetings response |
Meetings ≥ iso |
meetingsByNamePattern(raw, re) |
same | Meetings matching regex |
nameMatches(a, b) |
two strings | True if first-name tolerant match |
Assignee matching is fuzzy because Krisp inconsistently uses "Robert", "Robert Boulos", sometimes
null, sometimes "Speaker_3". The matcher normalizes + compares first names. Override the default
identity by setting KRISP_ME in .env.cache (defaults to "Robert Boulos").
Every helper has a stdin-piped CLI form so you can debug interactively:
bash# Fetch via MCP in-session, save to tmp, then pipe:
cat /tmp/krisp-items.json | npx tsx api.ts pending-for "Robert"
cat /tmp/krisp-items.json | npx tsx api.ts since 2026-04-01
cat /tmp/krisp-meetings.json | npx tsx api.ts commitments "Robert"
cat /tmp/krisp-meeting.json | npx tsx api.ts tool-mentions
mcp__krisp__get_multiple_documents directlyEach of these collapses to ~20–30 lines once this primitive exists:
ray-todo — stage last Friday's Ray-session items as actionable workkrisp-inbox — treat all pending Robert-assigned items as a work queuetool-scout — weekly digest of tool mentions from mastermindscommitment-audit — cross-check Krisp commitments against git/slack completion signalsmeeting-prep-doc — after each Mark sync, extract table/API references, merge into a living doc.env.cache as KRISP_ACCESS_TOKEN.completed field is not updated. When we dispatch an item and complete it, we have no way to write back. Mitigation: snappy-ops audit log is the source of truth; cross-reference by item id.<!-- SNAPPY-NEAR-NEIGHBOURS-START -->
These hands share enough of this one's words that a model can pick the wrong
door. Each row says what the other one is for; open that one instead when its
job is the job.
| Hand | What it is for |
|---|---|
snappy-calendar |
Google Calendar operations for Snappy -- view events, create meetings, check availability, schedule calls... |
<!-- SNAPPY-NEAR-NEIGHBOURS-END -->
---
name: snappy-krisp
description: "Turn Krisp meeting data into structured work for the snappy-ops chassis. Triggers on: action item extraction, meeting commitment tracking, tool-mention scouting, assignee filtering, dormant-ask detection."
---
# snappy-krisp — Krisp → Ops Bridge
Krisp is the inbound event stream of Robert's real-world leverage loop. Every
meeting produces signal — commitments, tool scouts, referral opportunities,
case study hooks — that today evaporates. This skill is the primitive that
turns that raw stream into staged work.
## Design
This skill is **pure transformers**. It does not call Krisp's API directly.
The auth lives inside the Claude Code MCP server (`mcp__krisp__*` tools), so
the calling agent is expected to fetch raw payloads via MCP and pipe them
through these helpers.
Headless/cron support (a proper HTTP client using an OAuth token pulled from
`.env.cache`) is a **documented GAP**. When we want cron-driven flows like
"Ray→ops bridge" or the "pre-call referral prompt" to run without a Claude
Code session attached, this primitive grows an HTTP layer. Until then,
recipes must be invoked from within a Claude Code context.
## API
```ts
import {
actionItemsFor, pendingActionItemsFor, actionItemsSince,
commitmentsFrom, extractToolMentions, meetingsSince, meetingsByNamePattern,
nameMatches,
type KrispActionItem, type KrispMeeting, type Commitment,
} from "../snappy-krisp/api.ts";
// Agent fetches raw via MCP…
const raw = await mcp__krisp__list_action_items({ limit: 50 });
// …then pipes it here.
const mine = pendingActionItemsFor(raw); // everything not-done assigned to me
const recent = actionItemsSince(mine, "2026-04-01"); // last two weeks
```
| Function | Input | Output |
|---|---|---|
| `actionItemsFor(raw, who?)` | MCP `list_action_items` response | Items matching assignee (fuzzy, first-name tolerant) |
| `pendingActionItemsFor(raw, who?)` | same | Same, filtered to `completed=false` |
| `actionItemsSince(raw, iso)` | same | Items whose meeting_date ≥ iso |
| `commitmentsFrom(raw, who?)` | `search_meetings` response w/ notes | Normalized commitments from meeting_notes.action_items |
| `extractToolMentions(text)` | free text (transcript, key_points) | Unique tool/URL/slug mentions |
| `meetingsSince(raw, iso)` | `search_meetings` response | Meetings ≥ iso |
| `meetingsByNamePattern(raw, re)` | same | Meetings matching regex |
| `nameMatches(a, b)` | two strings | True if first-name tolerant match |
**Assignee matching is fuzzy** because Krisp inconsistently uses "Robert", "Robert Boulos", sometimes
null, sometimes "Speaker_3". The matcher normalizes + compares first names. Override the default
identity by setting `KRISP_ME` in `.env.cache` (defaults to "Robert Boulos").
## CLI
Every helper has a stdin-piped CLI form so you can debug interactively:
```bash
# Fetch via MCP in-session, save to tmp, then pipe:
cat /tmp/krisp-items.json | npx tsx api.ts pending-for "Robert"
cat /tmp/krisp-items.json | npx tsx api.ts since 2026-04-01
cat /tmp/krisp-meetings.json | npx tsx api.ts commitments "Robert"
cat /tmp/krisp-meeting.json | npx tsx api.ts tool-mentions
```
## When to use
- A snappy-ops recipe needs to stage Robert's pending commitments from recent meetings
- You want to scout tool mentions from mastermind transcripts for a weekly "queue to try" list
- You want to bridge a specific meeting's action items into the work chassis (Ray sessions, Mark syncs)
- Any recipe that turns "what was said in a meeting" into "what should be dispatched next"
## When NOT to use
- You need to create/edit Krisp data — this skill is read-only transformation
- You need headless/cron access — blocked on the HTTP client GAP
- You want raw meeting transcripts verbatim — call `mcp__krisp__get_multiple_documents` directly
## Downstream recipes this unlocks
Each of these collapses to ~20–30 lines once this primitive exists:
1. **`ray-todo`** — stage last Friday's Ray-session items as actionable work
2. **`krisp-inbox`** — treat all pending Robert-assigned items as a work queue
3. **`tool-scout`** — weekly digest of tool mentions from masterminds
4. **`commitment-audit`** — cross-check Krisp commitments against git/slack completion signals
5. **`meeting-prep-doc`** — after each Mark sync, extract table/API references, merge into a living doc
## GAPs
- **Headless HTTP client.** Krisp has an underlying REST API with OAuth. Without this, no cron support. Blocker: need to introspect MCP server source or Krisp's OAuth flow to get a token into `.env.cache` as `KRISP_ACCESS_TOKEN`.
- **Completion feedback loop.** Krisp's `completed` field is not updated. When we dispatch an item and complete it, we have no way to write back. Mitigation: snappy-ops audit log is the source of truth; cross-reference by item id.
<!-- SNAPPY-NEAR-NEIGHBOURS-START -->
## Near neighbours
These hands share enough of this one's words that a model can pick the wrong
door. Each row says what the other one is for; open that one instead when its
job is the job.
| Hand | What it is for |
|---|---|
| `snappy-calendar` | Google Calendar operations for Snappy -- view events, create meetings, check availability, schedule calls... |
<!-- SNAPPY-NEAR-NEIGHBOURS-END -->
import assert from "node:assert/strict";
import test from "node:test";
import { HAND_CONTRACT, parseFetchMeetingsArgs } from "./api.ts";
import { exampleHazard } from "../snappy-tool-design/api.ts";
// R59 (2026-09-08): `fetch-meetings(after-date?, limit?)` taught
// `fetch-meetings 20` and filed the 20 as a date. RED before the count moved.
test("fetch-meetings' first call carries no rule-59 hazard", async () => {
assert.equal(await exampleHazard("snappy-krisp", "fetch-meetings"), null);
});
test("--limit 2 parses to limit 2, with and without an after-date", () => {
assert.equal(parseFetchMeetingsArgs(["--limit", "2"]).limit, 2);
const withDate = parseFetchMeetingsArgs(["2026-09-01", "--limit", "2"]);
assert.equal(withDate.limit, 2);
assert.equal(withDate.after, "2026-09-01");
});
test("a bare count is refused by name instead of becoming a date", () => {
const parsed = parseFetchMeetingsArgs(["2026-09-01", "20"]);
assert.match(parsed.refusal ?? "", /the count is a flag: fetch-meetings 2026-09-01 --limit 20/);
});
test("the contract declares limit as a flag, never a positional", () => {
assert.deepEqual([...HAND_CONTRACT.verbs["fetch-meetings"].args], ["after-date?"]);
assert.equal(HAND_CONTRACT.verbs["fetch-meetings"].flags.limit, "--limit");
});
import assert from "node:assert/strict";
import test from "node:test";
import { HAND_CONTRACT, parseFetchMeetingsArgs } from "./api.ts";
import { exampleHazard } from "../snappy-tool-design/api.ts";
// R59 (2026-09-08): `fetch-meetings(after-date?, limit?)` taught
// `fetch-meetings 20` and filed the 20 as a date. RED before the count moved.
test("fetch-meetings' first call carries no rule-59 hazard", async () => {
assert.equal(await exampleHazard("snappy-krisp", "fetch-meetings"), null);
});
test("--limit 2 parses to limit 2, with and without an after-date", () => {
assert.equal(parseFetchMeetingsArgs(["--limit", "2"]).limit, 2);
const withDate = parseFetchMeetingsArgs(["2026-09-01", "--limit", "2"]);
assert.equal(withDate.limit, 2);
assert.equal(withDate.after, "2026-09-01");
});
test("a bare count is refused by name instead of becoming a date", () => {
const parsed = parseFetchMeetingsArgs(["2026-09-01", "20"]);
assert.match(parsed.refusal ?? "", /the count is a flag: fetch-meetings 2026-09-01 --limit 20/);
});
test("the contract declares limit as a flag, never a positional", () => {
assert.deepEqual([...HAND_CONTRACT.verbs["fetch-meetings"].args], ["after-date?"]);
assert.equal(HAND_CONTRACT.verbs["fetch-meetings"].flags.limit, "--limit");
});
#!/usr/bin/env npx tsx
/**
* snappy-krisp/api.ts -- Krisp meeting data: headless HTTP client + helpers.
*
* Two paths to Krisp data:
* 1. MCP tools (available in Claude Code sessions with the Krisp MCP server)
* 2. Direct HTTP via fetchKrisp() — reads OAuth token from
* ~/.claude/.credentials.json, calls the MCP Streamable HTTP endpoint.
* Works headless in cron, dispatched agents, and scripts.
*
* The HTTP client auto-refreshes the OAuth token when expired.
*
* Usage:
* npx tsx api.ts filter-assignee "Robert" < items.json
* npx tsx api.ts tool-mentions < meeting.json
* npx tsx api.ts commitments "Robert" < meetings.json
*
* Module:
* import {
* actionItemsFor, actionItemsSince, extractToolMentions,
* commitmentsFrom, type KrispActionItem, type KrispMeeting,
* } from "../snappy-krisp/api.ts";
*/
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { env } from "../snappy-settings/load.ts";
import { realpathSync } from "fs";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// Default "me" identity. Override by setting KRISP_ME in .env.cache.
const ME = env("KRISP_ME", false) || "Robert Boulos";
// Cache directory — recipes read from here, the /snappy-ops agent writes to here
// after fetching fresh data via the Krisp MCP tool family.
export const CACHE_DIR = path.join(os.homedir(), ".claude", "cache", "krisp");
const ITEMS_PATH = path.join(CACHE_DIR, "action-items.json");
const MEETINGS_PATH = path.join(CACHE_DIR, "meetings.json");
// ---- types (matching Krisp MCP response shape) ----
export type KrispActionItem = {
id: string;
title: string;
completed: boolean;
assignee: string | null;
due_date: string | null;
meeting_id: string;
meeting_name: string;
meeting_date: string;
};
export type KrispActionItemList = {
filters?: Record<string, unknown>;
action_items: KrispActionItem[];
count?: number;
total?: number;
};
export type KrispMeetingNotes = {
key_points?: string[];
action_items?: Array<{ title: string; completed?: boolean; assignee?: string | null }>;
detailed_summary?: string;
};
export type KrispMeeting = {
meeting_id: string;
name: string;
date: string;
attendees?: string[];
speakers?: string[];
meeting_notes?: KrispMeetingNotes;
};
export type KrispMeetingList = {
criteria?: Record<string, unknown>;
meetings: KrispMeeting[];
count?: number;
};
// ---- assignee matching (fuzzy, first-name tolerant) ----
function normalizeName(s: string): string {
return s.trim().toLowerCase().replace(/[^a-z\s]/g, "");
}
export function nameMatches(assignee: string | null | undefined, target: string): boolean {
if (!assignee) return false;
const a = normalizeName(assignee);
const t = normalizeName(target);
if (!a || !t) return false;
if (a === t) return true;
// first-name tolerant: "Robert" matches "Robert Boulos", "robert b", etc.
const tFirst = t.split(/\s+/)[0];
const aFirst = a.split(/\s+/)[0];
return aFirst === tFirst;
}
/**
* Title-prefix fallback. Krisp leaves assignee=null on many items but the
* title still starts with "Robert to ...". Match the first name at word
* boundary to avoid false positives ("Robertson" etc.).
*/
export function titleAssignedTo(title: string, target: string): boolean {
if (!title || !target) return false;
const first = normalizeName(target).split(/\s+/)[0];
if (!first) return false;
const re = new RegExp(`^${first}\\b[^a-z]`, "i");
return re.test(title.trim());
}
// ---- cache (filesystem snapshot the /snappy-ops agent refreshes) ----
function ensureCacheDir() {
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
}
export function writeItemsCache(raw: unknown): string {
ensureCacheDir();
fs.writeFileSync(ITEMS_PATH, JSON.stringify(raw, null, 2));
return ITEMS_PATH;
}
export function writeMeetingsCache(raw: unknown): string {
ensureCacheDir();
fs.writeFileSync(MEETINGS_PATH, JSON.stringify(raw, null, 2));
return MEETINGS_PATH;
}
export type CacheRead<T> = { data: T | null; path: string; age_minutes: number | null; exists: boolean };
export function readItemsCache(): CacheRead<KrispActionItemList> {
if (!fs.existsSync(ITEMS_PATH)) return { data: null, path: ITEMS_PATH, age_minutes: null, exists: false };
const stat = fs.statSync(ITEMS_PATH);
const age = Math.round((Date.now() - stat.mtimeMs) / 60000);
const data = JSON.parse(fs.readFileSync(ITEMS_PATH, "utf8")) as KrispActionItemList;
return { data, path: ITEMS_PATH, age_minutes: age, exists: true };
}
export function readMeetingsCache(): CacheRead<KrispMeetingList> {
if (!fs.existsSync(MEETINGS_PATH)) return { data: null, path: MEETINGS_PATH, age_minutes: null, exists: false };
const stat = fs.statSync(MEETINGS_PATH);
const age = Math.round((Date.now() - stat.mtimeMs) / 60000);
const data = JSON.parse(fs.readFileSync(MEETINGS_PATH, "utf8")) as KrispMeetingList;
return { data, path: MEETINGS_PATH, age_minutes: age, exists: true };
}
// ---- action item filters ----
/** Unwrap the common MCP action-item response shape. */
function asItems(raw: unknown): KrispActionItem[] {
if (Array.isArray(raw)) return raw as KrispActionItem[];
const obj = raw as { action_items?: KrispActionItem[] } | null;
return obj?.action_items ?? [];
}
export function actionItemsFor(
raw: KrispActionItemList | KrispActionItem[],
assignee: string = ME,
): KrispActionItem[] {
return asItems(raw).filter(
(i) => nameMatches(i.assignee, assignee) || titleAssignedTo(i.title, assignee),
);
}
export function actionItemsSince(
raw: KrispActionItemList | KrispActionItem[],
sinceIso: string,
): KrispActionItem[] {
const cutoff = Date.parse(sinceIso);
if (Number.isNaN(cutoff)) return asItems(raw);
return asItems(raw).filter((i) => {
const t = Date.parse(i.meeting_date);
return !Number.isNaN(t) && t >= cutoff;
});
}
export function pendingActionItemsFor(
raw: KrispActionItemList | KrispActionItem[],
assignee: string = ME,
): KrispActionItem[] {
return actionItemsFor(raw, assignee).filter((i) => !i.completed);
}
// ---- commitments extracted from meeting notes (when Krisp returns full notes) ----
export type Commitment = {
title: string;
assignee: string | null;
meeting_id: string;
meeting_name: string;
meeting_date: string;
};
export function commitmentsFrom(
raw: KrispMeetingList | KrispMeeting[],
assignee: string = ME,
): Commitment[] {
const meetings: KrispMeeting[] = Array.isArray(raw) ? raw : raw?.meetings ?? [];
const out: Commitment[] = [];
for (const m of meetings) {
const items = m.meeting_notes?.action_items ?? [];
for (const it of items) {
if (!nameMatches(it.assignee ?? null, assignee)) continue;
if (it.completed) continue;
out.push({
title: it.title.trim(),
assignee: it.assignee ?? null,
meeting_id: m.meeting_id,
meeting_name: m.name,
meeting_date: m.date,
});
}
}
return out;
}
// ---- tool-mention extraction ----
// Patterns that consistently produce useful hits in the mastermind transcripts:
// "try X", "check out X", "use X", bare URLs, and "Xyz.dev / Xyz.io / Xyz.ai" product slugs.
const TRY_RE = /\b(?:try|check out|explore|use|test|play with|look at|review)\s+([A-Z][A-Za-z0-9][\w.-]{1,40})/g;
const URL_RE = /\bhttps?:\/\/[^\s)]+/g;
const SLUG_RE = /\b([A-Z][A-Za-z0-9]{2,20}\.(?:dev|io|ai|com|app|sh))\b/g;
export function extractToolMentions(text: string): string[] {
const hits = new Set<string>();
for (const m of text.matchAll(TRY_RE)) hits.add(m[1]);
for (const m of text.matchAll(URL_RE)) hits.add(m[0]);
for (const m of text.matchAll(SLUG_RE)) hits.add(m[1]);
return Array.from(hits);
}
// ---- meeting filters ----
export function meetingsSince(
raw: KrispMeetingList | KrispMeeting[],
sinceIso: string,
): KrispMeeting[] {
const meetings: KrispMeeting[] = Array.isArray(raw) ? raw : raw?.meetings ?? [];
const cutoff = Date.parse(sinceIso);
if (Number.isNaN(cutoff)) return meetings;
return meetings.filter((m) => {
const t = Date.parse(m.date);
return !Number.isNaN(t) && t >= cutoff;
});
}
export function meetingsByNamePattern(
raw: KrispMeetingList | KrispMeeting[],
pattern: RegExp,
): KrispMeeting[] {
const meetings: KrispMeeting[] = Array.isArray(raw) ? raw : raw?.meetings ?? [];
return meetings.filter((m) => pattern.test(m.name));
}
// ---- headless HTTP client (reads OAuth from ~/.claude/.credentials.json) ----
const CREDENTIALS_PATH = path.join(os.homedir(), ".claude", ".credentials.json");
const MCP_ENDPOINT = "https://mcp.krisp.ai/mcp";
const OAUTH_KEY = "krisp|bcb95cd29baab550";
type KrispCredentials = {
accessToken: string;
refreshToken: string;
expiresAt: number;
clientId: string;
clientSecret: string;
serverUrl: string;
};
function readCredentials(): KrispCredentials {
const raw = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf-8"));
const entry = raw?.mcpOAuth?.[OAUTH_KEY];
if (!entry?.accessToken) throw new Error("No Krisp OAuth token in .credentials.json");
return entry as KrispCredentials;
}
function writeCredentials(creds: KrispCredentials): void {
const raw = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf-8"));
raw.mcpOAuth[OAUTH_KEY] = { ...raw.mcpOAuth[OAUTH_KEY], ...creds };
fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(raw, null, 2));
}
async function refreshToken(creds: KrispCredentials): Promise<KrispCredentials> {
const r = await fetch("https://mcp.krisp.ai/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: creds.clientId,
client_secret: creds.clientSecret,
refresh_token: creds.refreshToken,
}),
});
if (!r.ok) throw new Error(`Krisp token refresh failed: ${r.status}`);
const j: any = await r.json();
const updated: KrispCredentials = {
...creds,
accessToken: j.access_token,
refreshToken: j.refresh_token || creds.refreshToken,
expiresAt: Date.now() + (j.expires_in || 3600) * 1000,
};
writeCredentials(updated);
return updated;
}
async function getValidToken(): Promise<string> {
let creds = readCredentials();
// Refresh if within 5 minutes of expiry
if (creds.expiresAt < Date.now() + 300_000) {
creds = await refreshToken(creds);
}
return creds.accessToken;
}
/**
* Call a Krisp MCP tool via direct HTTP. Works headless — no MCP server needed.
* Returns the structuredContent from the response, or the raw text content.
*/
export async function fetchKrisp(
toolName: string,
args: Record<string, unknown> = {},
): Promise<any> {
const token = await getValidToken();
const body = {
jsonrpc: "2.0",
id: Date.now(),
method: "tools/call",
params: { name: toolName, arguments: args },
};
const r = await fetch(MCP_ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`Krisp HTTP ${r.status}: ${await r.text()}`);
const raw = await r.text();
// Parse SSE: find the data: line with our JSON-RPC response
for (const line of raw.split("\n")) {
if (!line.startsWith("data: ")) continue;
const frame = JSON.parse(line.slice(6));
const result = frame?.result;
if (!result) continue;
// Prefer structuredContent (typed JSON), fall back to text content
if (result.structuredContent) return result.structuredContent;
const texts = (result.content || [])
.filter((c: any) => c.type === "text")
.map((c: any) => c.text);
if (texts.length === 1) {
try { return JSON.parse(texts[0]); } catch { return texts[0]; }
}
return texts.join("\n");
}
throw new Error("No data frame in Krisp SSE response");
}
/** Fetch meetings in a date range. Headless — no MCP server needed. */
export async function fetchMeetings(opts: {
after?: string;
before?: string;
limit?: number;
fields?: string[];
} = {}): Promise<KrispMeetingList> {
const args: Record<string, unknown> = {};
if (opts.after) args.after = opts.after;
if (opts.before) args.before = opts.before;
if (opts.limit) args.limit = opts.limit;
if (opts.fields) args.fields = opts.fields;
return fetchKrisp("search_meetings", args) as Promise<KrispMeetingList>;
}
/** Fetch full meeting document (transcript, notes, etc.) by ID. Headless. */
export async function fetchMeetingDocument(meetingId: string): Promise<string | null> {
const result = await fetchKrisp("get_multiple_documents", { ids: [meetingId] });
// structuredContent returns {results: [{id, document}], requestedCount, foundCount}
const docs = result?.results || (Array.isArray(result) ? result : result?.documents || []);
if (!Array.isArray(docs) || !docs.length) return null;
return docs[0]?.document || null;
}
/** Fetch action items. Headless. */
export async function fetchActionItems(opts: {
assignedToMe?: boolean;
completed?: boolean;
limit?: number;
} = {}): Promise<KrispActionItemList> {
const args: Record<string, unknown> = {};
if (opts.assignedToMe != null) args.assigned_to_me = opts.assignedToMe;
if (opts.completed != null) args.completed = opts.completed;
if (opts.limit) args.limit = opts.limit;
return fetchKrisp("list_action_items", args) as Promise<KrispActionItemList>;
}
/** THE `krisp-actions` FACE'S OWN ROWS, added beside the vendor's.
*
* The face draws `{ total, items: [{ what, owner, meeting, due }] }`
* (`faces/fixtures/krisp-actions.json`, beside this file — `snappy-faces/
* face-homes.ts` is the one answer to where any family's example lives) and Krisp answers
* `{ action_items: [{ title, assignee, meeting_name, due_date }] }`. Neither
* vocabulary is wrong and neither gets renamed: this is ADDITIVE, so every
* existing reader of `action_items` keeps working and the face has the words
* it binds to. A renamed key would be a wire change to a read that has
* callers ⟨CLAUDE.md §11⟩. */
export function actionItemsFace(list: KrispActionItemList): KrispActionItemList & { total: number; items: { what: string; owner?: string; meeting?: string; due?: string }[] } {
const rows = Array.isArray(list?.action_items) ? list.action_items : [];
return {
...list,
total: list?.total ?? list?.count ?? rows.length,
items: rows.map((item) => ({
what: item.title,
...(item.assignee ? { owner: item.assignee } : {}),
...(item.meeting_name ? { meeting: item.meeting_name } : {}),
...(item.due_date ? { due: item.due_date } : {}),
})),
};
}
// ---- CLI ----
async function readStdin(): Promise<string> {
if (process.stdin.isTTY) return "";
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString("utf8");
}
function json(x: unknown) { console.log(JSON.stringify(x, null, 2)); }
// --- Metrics (Step 7a) ---
const STAGED_ACTIONS_LOG = `${process.env.HOME}/.claude/logs/staged-actions.ndjson`;
type StagedRun = { ts: string; name: string; action: string };
function readStagedRunsKrisp(): StagedRun[] {
if (!fs.existsSync(STAGED_ACTIONS_LOG)) return [];
const out: StagedRun[] = [];
for (const line of fs.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 withinLastDaysKrisp(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 computeKrispMetric(name: string): number | null {
const runs = readStagedRunsKrisp().filter((r) => withinLastDaysKrisp(r.ts, 7));
switch (name) {
case "inbox-per-week":
case "krisp_inbox_runs_per_week":
return runs.filter((r) => r.name === "krisp-inbox").length;
case "audit-per-week":
case "commitment_audit_runs_per_week":
return runs.filter((r) => r.name === "commitment-audit").length;
default:
return null;
}
}
/** 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-krisp",
description: "Turn Krisp meeting data into structured work for the snappy-ops chassis. Triggers on: action item extraction, meeting commitment tracking, tool-mention scouting, assignee filtering, dormant-ask detection.",
managed: true,
requires: [] as string[],
/**
* ONLY THE VERBS A CALLER CAN ACTUALLY REACH ⟨lane SONAR-JOBS, 2026-09-07⟩.
*
* This declared ten. SEVEN of them — `commitments`, `filter-assignee`,
* `meetings-since`, `pending-for`, `since`, `tool-mentions` and the `since`
* family generally — are LOCAL FILTERS that read their input from STDIN
* (`readStdin()`, the "-- local filter commands (read from stdin) --" arm
* below). Every road into a hand spawns it with no stdin: `POST /hands/run`,
* `POST /hands/stage`, `snappy_connector_action` and the approval executor all
* go through `spawnHand`, where `process.stdin.isTTY` is false and the read
* returns "". So `parsed` is null and the filter answers `[]` — SILENTLY, exit
* 0, shaped exactly like a real empty week.
*
* MEASURED, and it cost months. JOB 10 (Krisp idea cards) was armed on
* `meetings-since` and reported "MEETINGS: 0" every single day, indistinguishable
* from a quiet calendar; on 2026-09-07 a run piped `fetch-meetings` into it and
* the same window answered live for the first time since the mirror went stale
* in June. An advertised door that can only ever answer empty is worse than an
* absent one, because nobody goes looking for the absent one — which is the
* whole reason this contract exists (the same defect was fixed in snappy-gmail's
* `thread` the same day).
*
* The filters are NOT deleted: they are a shell pipeline vocabulary and the CLI
* still runs them, with their usage lines. They are simply not DOORS, so they
* are not declared as ones. The pipeline is
* `fetch-meetings <after> --limit <n> | meetings-since <iso>` and it belongs in the
* words that ask for it, never behind a verb name that implies a live read.
*/
refusals: refusalTable("unknown_verb", "missing_argument", "missing_credential", "not_found", "upstream_error"),
verbs: {
// Headless HTTP: these reach Krisp and answer rows with no stdin.
// THE COUNT IS A FLAG, NOT THE SECOND WORD (R59, measured 2026-09-08).
// `limit` used to be the second positional behind an optional date, so a
// bare `fetch-meetings 20` filed the 20 as an AFTER-DATE -- an unparseable
// day that answered a window nobody asked for. `--limit N` is the
// collection's spelling (snappy-github `repos`, snappy-update `commits`).
"fetch-meetings": {
args: ["after-date?"], effect: "read", flags: { limit: "--limit" },
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { "after-date": { type: "string", description: "ISO date; only meetings after it are returned" }, limit: { type: "integer", description: "How many meetings to return; the count is the FLAG --limit, never the second word", default: 20, maximum: 200 } } },
},
"fetch-document": {
args: ["meeting-id"], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { "meeting-id": { type: "string", description: "Meeting whose transcript document is read" } } },
},
"fetch-action-items": {
// THE FACE THIS READ DRAWS, NAMED BY THE HAND ⟨2026-09-09⟩. The runner
// used to derive it from two words — this hand's name against the faces
// manifest's family slugs, and the verb's own word against its six
// shapes — and `fetch-action-items` folds onto NONE of those shapes, so
// the whole `krisp` family had no read that reached it while this hand
// answered for it every day. The hand knows; the derivation guesses.
face: "krisp-actions",
args: ["limit?"], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { limit: { type: "integer", description: "How many action items to return", default: 50, maximum: 500 } } },
},
// Computed on this Mac from the store it already holds; no stdin either.
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 Krisp metric to compute" } } },
},
},
} 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);
}
/** THE GRAMMAR OF `fetch-meetings`, owned in ONE place so the CLI and its test
* read the same words. A leftover positional count REFUSES by name rather than
* being read as a date: a date this API cannot parse answers a window nobody
* asked for, and looks exactly like a quiet week. */
export function parseFetchMeetingsArgs(args: string[]): { after: string; limit: number; refusal?: string } {
const positional = args.filter((arg, index) => !arg.startsWith("--") && args[index - 1] !== "--limit");
const limitAt = args.indexOf("--limit");
const limit = limitAt >= 0 ? Number(args[limitAt + 1]) || 20 : 20;
const after = positional[0] || new Date(Date.now() - 86400_000).toISOString().slice(0, 10);
if (positional.length > 1) {
return { after, limit, refusal: `fetch-meetings takes one after-date; the count is a flag: fetch-meetings ${positional[0]} --limit ${positional[1]}` };
}
return { after, limit };
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
if (cmd === "metrics") {
const [name, ...rest] = args;
if (!name) {
console.error("Usage: api.ts metrics <name> [--json]");
console.error("Names: inbox-per-week, audit-per-week");
process.exit(1);
}
const value = computeKrispMetric(name);
if (rest.includes("--json")) console.log(JSON.stringify({ value }));
else console.log(value == null ? "null" : String(value));
return;
}
const raw = await readStdin();
const parsed = raw ? JSON.parse(raw) : null;
switch (cmd) {
// -- headless HTTP commands (no stdin needed) --
case "fetch-meetings": {
const { after, limit, refusal } = parseFetchMeetingsArgs(args);
if (refusal) { console.error(refusal); process.exit(1); }
json(await fetchMeetings({ after, limit, fields: ["name", "date", "attendees", "speakers", "meeting_notes"] }));
break;
}
case "fetch-document": {
const id = args[0];
if (!id) { console.error("Usage: api.ts fetch-document <meeting_id>"); process.exit(1); }
json(await fetchMeetingDocument(id));
break;
}
case "fetch-action-items": {
const limit = parseInt(args[0] || "30", 10);
// THE FACE BINDS TO WHAT THE HAND PRINTS, so the `krisp-actions` rows
// are printed BESIDE the vendor's own — `items`/`total` added, nothing
// renamed and nothing dropped, because `action_items` is the shape
// every existing reader of this verb already reads.
json(actionItemsFace(await fetchActionItems({ limit })));
break;
}
// -- local filter commands (read from stdin) --
case "filter-assignee": {
const who = args[0] || ME;
json(actionItemsFor(parsed, who));
break;
}
case "pending-for": {
const who = args[0] || ME;
json(pendingActionItemsFor(parsed, who));
break;
}
case "since": {
const iso = args[0];
if (!iso) { console.error("Usage: api.ts since <iso-date> < items.json"); process.exit(1); }
json(actionItemsSince(parsed, iso));
break;
}
case "commitments": {
const who = args[0] || ME;
json(commitmentsFrom(parsed, who));
break;
}
case "tool-mentions": {
const text = typeof parsed === "string" ? parsed : JSON.stringify(parsed);
json(extractToolMentions(text));
break;
}
case "meetings-since": {
const iso = args[0];
if (!iso) { console.error("Usage: api.ts meetings-since <iso-date> < meetings.json"); process.exit(1); }
json(meetingsSince(parsed, iso));
break;
}
default:
console.log(`Usage: npx tsx api.ts <command> [args]
Headless HTTP (no stdin):
fetch-meetings [after-date] [--limit N] Fetch meetings from Krisp API (default: last 24h, 20 rows)
fetch-document <meeting_id> Fetch full document for a meeting
fetch-action-items [limit] Fetch action items
Local filters (pipe JSON via stdin):
filter-assignee [name] Filter action items by assignee
pending-for [name] Pending items for assignee
since <iso-date> Items since date
commitments [name] Commitments from meeting notes
tool-mentions Extract tool/product mentions
meetings-since <iso-date> Meetings since date
Other:
metrics <name> [--json] Compute krisp metric (inbox-per-week, audit-per-week)
Default assignee: ${ME} (set KRISP_ME in .env.cache to override)`);
}
})();
}
#!/usr/bin/env npx tsx
/**
* snappy-krisp/api.ts -- Krisp meeting data: headless HTTP client + helpers.
*
* Two paths to Krisp data:
* 1. MCP tools (available in Claude Code sessions with the Krisp MCP server)
* 2. Direct HTTP via fetchKrisp() — reads OAuth token from
* ~/.claude/.credentials.json, calls the MCP Streamable HTTP endpoint.
* Works headless in cron, dispatched agents, and scripts.
*
* The HTTP client auto-refreshes the OAuth token when expired.
*
* Usage:
* npx tsx api.ts filter-assignee "Robert" < items.json
* npx tsx api.ts tool-mentions < meeting.json
* npx tsx api.ts commitments "Robert" < meetings.json
*
* Module:
* import {
* actionItemsFor, actionItemsSince, extractToolMentions,
* commitmentsFrom, type KrispActionItem, type KrispMeeting,
* } from "../snappy-krisp/api.ts";
*/
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { env } from "../snappy-settings/load.ts";
import { realpathSync } from "fs";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// Default "me" identity. Override by setting KRISP_ME in .env.cache.
const ME = env("KRISP_ME", false) || "Robert Boulos";
// Cache directory — recipes read from here, the /snappy-ops agent writes to here
// after fetching fresh data via the Krisp MCP tool family.
export const CACHE_DIR = path.join(os.homedir(), ".claude", "cache", "krisp");
const ITEMS_PATH = path.join(CACHE_DIR, "action-items.json");
const MEETINGS_PATH = path.join(CACHE_DIR, "meetings.json");
// ---- types (matching Krisp MCP response shape) ----
export type KrispActionItem = {
id: string;
title: string;
completed: boolean;
assignee: string | null;
due_date: string | null;
meeting_id: string;
meeting_name: string;
meeting_date: string;
};
export type KrispActionItemList = {
filters?: Record<string, unknown>;
action_items: KrispActionItem[];
count?: number;
total?: number;
};
export type KrispMeetingNotes = {
key_points?: string[];
action_items?: Array<{ title: string; completed?: boolean; assignee?: string | null }>;
detailed_summary?: string;
};
export type KrispMeeting = {
meeting_id: string;
name: string;
date: string;
attendees?: string[];
speakers?: string[];
meeting_notes?: KrispMeetingNotes;
};
export type KrispMeetingList = {
criteria?: Record<string, unknown>;
meetings: KrispMeeting[];
count?: number;
};
// ---- assignee matching (fuzzy, first-name tolerant) ----
function normalizeName(s: string): string {
return s.trim().toLowerCase().replace(/[^a-z\s]/g, "");
}
export function nameMatches(assignee: string | null | undefined, target: string): boolean {
if (!assignee) return false;
const a = normalizeName(assignee);
const t = normalizeName(target);
if (!a || !t) return false;
if (a === t) return true;
// first-name tolerant: "Robert" matches "Robert Boulos", "robert b", etc.
const tFirst = t.split(/\s+/)[0];
const aFirst = a.split(/\s+/)[0];
return aFirst === tFirst;
}
/**
* Title-prefix fallback. Krisp leaves assignee=null on many items but the
* title still starts with "Robert to ...". Match the first name at word
* boundary to avoid false positives ("Robertson" etc.).
*/
export function titleAssignedTo(title: string, target: string): boolean {
if (!title || !target) return false;
const first = normalizeName(target).split(/\s+/)[0];
if (!first) return false;
const re = new RegExp(`^${first}\\b[^a-z]`, "i");
return re.test(title.trim());
}
// ---- cache (filesystem snapshot the /snappy-ops agent refreshes) ----
function ensureCacheDir() {
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
}
export function writeItemsCache(raw: unknown): string {
ensureCacheDir();
fs.writeFileSync(ITEMS_PATH, JSON.stringify(raw, null, 2));
return ITEMS_PATH;
}
export function writeMeetingsCache(raw: unknown): string {
ensureCacheDir();
fs.writeFileSync(MEETINGS_PATH, JSON.stringify(raw, null, 2));
return MEETINGS_PATH;
}
export type CacheRead<T> = { data: T | null; path: string; age_minutes: number | null; exists: boolean };
export function readItemsCache(): CacheRead<KrispActionItemList> {
if (!fs.existsSync(ITEMS_PATH)) return { data: null, path: ITEMS_PATH, age_minutes: null, exists: false };
const stat = fs.statSync(ITEMS_PATH);
const age = Math.round((Date.now() - stat.mtimeMs) / 60000);
const data = JSON.parse(fs.readFileSync(ITEMS_PATH, "utf8")) as KrispActionItemList;
return { data, path: ITEMS_PATH, age_minutes: age, exists: true };
}
export function readMeetingsCache(): CacheRead<KrispMeetingList> {
if (!fs.existsSync(MEETINGS_PATH)) return { data: null, path: MEETINGS_PATH, age_minutes: null, exists: false };
const stat = fs.statSync(MEETINGS_PATH);
const age = Math.round((Date.now() - stat.mtimeMs) / 60000);
const data = JSON.parse(fs.readFileSync(MEETINGS_PATH, "utf8")) as KrispMeetingList;
return { data, path: MEETINGS_PATH, age_minutes: age, exists: true };
}
// ---- action item filters ----
/** Unwrap the common MCP action-item response shape. */
function asItems(raw: unknown): KrispActionItem[] {
if (Array.isArray(raw)) return raw as KrispActionItem[];
const obj = raw as { action_items?: KrispActionItem[] } | null;
return obj?.action_items ?? [];
}
export function actionItemsFor(
raw: KrispActionItemList | KrispActionItem[],
assignee: string = ME,
): KrispActionItem[] {
return asItems(raw).filter(
(i) => nameMatches(i.assignee, assignee) || titleAssignedTo(i.title, assignee),
);
}
export function actionItemsSince(
raw: KrispActionItemList | KrispActionItem[],
sinceIso: string,
): KrispActionItem[] {
const cutoff = Date.parse(sinceIso);
if (Number.isNaN(cutoff)) return asItems(raw);
return asItems(raw).filter((i) => {
const t = Date.parse(i.meeting_date);
return !Number.isNaN(t) && t >= cutoff;
});
}
export function pendingActionItemsFor(
raw: KrispActionItemList | KrispActionItem[],
assignee: string = ME,
): KrispActionItem[] {
return actionItemsFor(raw, assignee).filter((i) => !i.completed);
}
// ---- commitments extracted from meeting notes (when Krisp returns full notes) ----
export type Commitment = {
title: string;
assignee: string | null;
meeting_id: string;
meeting_name: string;
meeting_date: string;
};
export function commitmentsFrom(
raw: KrispMeetingList | KrispMeeting[],
assignee: string = ME,
): Commitment[] {
const meetings: KrispMeeting[] = Array.isArray(raw) ? raw : raw?.meetings ?? [];
const out: Commitment[] = [];
for (const m of meetings) {
const items = m.meeting_notes?.action_items ?? [];
for (const it of items) {
if (!nameMatches(it.assignee ?? null, assignee)) continue;
if (it.completed) continue;
out.push({
title: it.title.trim(),
assignee: it.assignee ?? null,
meeting_id: m.meeting_id,
meeting_name: m.name,
meeting_date: m.date,
});
}
}
return out;
}
// ---- tool-mention extraction ----
// Patterns that consistently produce useful hits in the mastermind transcripts:
// "try X", "check out X", "use X", bare URLs, and "Xyz.dev / Xyz.io / Xyz.ai" product slugs.
const TRY_RE = /\b(?:try|check out|explore|use|test|play with|look at|review)\s+([A-Z][A-Za-z0-9][\w.-]{1,40})/g;
const URL_RE = /\bhttps?:\/\/[^\s)]+/g;
const SLUG_RE = /\b([A-Z][A-Za-z0-9]{2,20}\.(?:dev|io|ai|com|app|sh))\b/g;
export function extractToolMentions(text: string): string[] {
const hits = new Set<string>();
for (const m of text.matchAll(TRY_RE)) hits.add(m[1]);
for (const m of text.matchAll(URL_RE)) hits.add(m[0]);
for (const m of text.matchAll(SLUG_RE)) hits.add(m[1]);
return Array.from(hits);
}
// ---- meeting filters ----
export function meetingsSince(
raw: KrispMeetingList | KrispMeeting[],
sinceIso: string,
): KrispMeeting[] {
const meetings: KrispMeeting[] = Array.isArray(raw) ? raw : raw?.meetings ?? [];
const cutoff = Date.parse(sinceIso);
if (Number.isNaN(cutoff)) return meetings;
return meetings.filter((m) => {
const t = Date.parse(m.date);
return !Number.isNaN(t) && t >= cutoff;
});
}
export function meetingsByNamePattern(
raw: KrispMeetingList | KrispMeeting[],
pattern: RegExp,
): KrispMeeting[] {
const meetings: KrispMeeting[] = Array.isArray(raw) ? raw : raw?.meetings ?? [];
return meetings.filter((m) => pattern.test(m.name));
}
// ---- headless HTTP client (reads OAuth from ~/.claude/.credentials.json) ----
const CREDENTIALS_PATH = path.join(os.homedir(), ".claude", ".credentials.json");
const MCP_ENDPOINT = "https://mcp.krisp.ai/mcp";
const OAUTH_KEY = "krisp|bcb95cd29baab550";
type KrispCredentials = {
accessToken: string;
refreshToken: string;
expiresAt: number;
clientId: string;
clientSecret: string;
serverUrl: string;
};
function readCredentials(): KrispCredentials {
const raw = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf-8"));
const entry = raw?.mcpOAuth?.[OAUTH_KEY];
if (!entry?.accessToken) throw new Error("No Krisp OAuth token in .credentials.json");
return entry as KrispCredentials;
}
function writeCredentials(creds: KrispCredentials): void {
const raw = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf-8"));
raw.mcpOAuth[OAUTH_KEY] = { ...raw.mcpOAuth[OAUTH_KEY], ...creds };
fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(raw, null, 2));
}
async function refreshToken(creds: KrispCredentials): Promise<KrispCredentials> {
const r = await fetch("https://mcp.krisp.ai/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: creds.clientId,
client_secret: creds.clientSecret,
refresh_token: creds.refreshToken,
}),
});
if (!r.ok) throw new Error(`Krisp token refresh failed: ${r.status}`);
const j: any = await r.json();
const updated: KrispCredentials = {
...creds,
accessToken: j.access_token,
refreshToken: j.refresh_token || creds.refreshToken,
expiresAt: Date.now() + (j.expires_in || 3600) * 1000,
};
writeCredentials(updated);
return updated;
}
async function getValidToken(): Promise<string> {
let creds = readCredentials();
// Refresh if within 5 minutes of expiry
if (creds.expiresAt < Date.now() + 300_000) {
creds = await refreshToken(creds);
}
return creds.accessToken;
}
/**
* Call a Krisp MCP tool via direct HTTP. Works headless — no MCP server needed.
* Returns the structuredContent from the response, or the raw text content.
*/
export async function fetchKrisp(
toolName: string,
args: Record<string, unknown> = {},
): Promise<any> {
const token = await getValidToken();
const body = {
jsonrpc: "2.0",
id: Date.now(),
method: "tools/call",
params: { name: toolName, arguments: args },
};
const r = await fetch(MCP_ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`Krisp HTTP ${r.status}: ${await r.text()}`);
const raw = await r.text();
// Parse SSE: find the data: line with our JSON-RPC response
for (const line of raw.split("\n")) {
if (!line.startsWith("data: ")) continue;
const frame = JSON.parse(line.slice(6));
const result = frame?.result;
if (!result) continue;
// Prefer structuredContent (typed JSON), fall back to text content
if (result.structuredContent) return result.structuredContent;
const texts = (result.content || [])
.filter((c: any) => c.type === "text")
.map((c: any) => c.text);
if (texts.length === 1) {
try { return JSON.parse(texts[0]); } catch { return texts[0]; }
}
return texts.join("\n");
}
throw new Error("No data frame in Krisp SSE response");
}
/** Fetch meetings in a date range. Headless — no MCP server needed. */
export async function fetchMeetings(opts: {
after?: string;
before?: string;
limit?: number;
fields?: string[];
} = {}): Promise<KrispMeetingList> {
const args: Record<string, unknown> = {};
if (opts.after) args.after = opts.after;
if (opts.before) args.before = opts.before;
if (opts.limit) args.limit = opts.limit;
if (opts.fields) args.fields = opts.fields;
return fetchKrisp("search_meetings", args) as Promise<KrispMeetingList>;
}
/** Fetch full meeting document (transcript, notes, etc.) by ID. Headless. */
export async function fetchMeetingDocument(meetingId: string): Promise<string | null> {
const result = await fetchKrisp("get_multiple_documents", { ids: [meetingId] });
// structuredContent returns {results: [{id, document}], requestedCount, foundCount}
const docs = result?.results || (Array.isArray(result) ? result : result?.documents || []);
if (!Array.isArray(docs) || !docs.length) return null;
return docs[0]?.document || null;
}
/** Fetch action items. Headless. */
export async function fetchActionItems(opts: {
assignedToMe?: boolean;
completed?: boolean;
limit?: number;
} = {}): Promise<KrispActionItemList> {
const args: Record<string, unknown> = {};
if (opts.assignedToMe != null) args.assigned_to_me = opts.assignedToMe;
if (opts.completed != null) args.completed = opts.completed;
if (opts.limit) args.limit = opts.limit;
return fetchKrisp("list_action_items", args) as Promise<KrispActionItemList>;
}
/** THE `krisp-actions` FACE'S OWN ROWS, added beside the vendor's.
*
* The face draws `{ total, items: [{ what, owner, meeting, due }] }`
* (`faces/fixtures/krisp-actions.json`, beside this file — `snappy-faces/
* face-homes.ts` is the one answer to where any family's example lives) and Krisp answers
* `{ action_items: [{ title, assignee, meeting_name, due_date }] }`. Neither
* vocabulary is wrong and neither gets renamed: this is ADDITIVE, so every
* existing reader of `action_items` keeps working and the face has the words
* it binds to. A renamed key would be a wire change to a read that has
* callers ⟨CLAUDE.md §11⟩. */
export function actionItemsFace(list: KrispActionItemList): KrispActionItemList & { total: number; items: { what: string; owner?: string; meeting?: string; due?: string }[] } {
const rows = Array.isArray(list?.action_items) ? list.action_items : [];
return {
...list,
total: list?.total ?? list?.count ?? rows.length,
items: rows.map((item) => ({
what: item.title,
...(item.assignee ? { owner: item.assignee } : {}),
...(item.meeting_name ? { meeting: item.meeting_name } : {}),
...(item.due_date ? { due: item.due_date } : {}),
})),
};
}
// ---- CLI ----
async function readStdin(): Promise<string> {
if (process.stdin.isTTY) return "";
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString("utf8");
}
function json(x: unknown) { console.log(JSON.stringify(x, null, 2)); }
// --- Metrics (Step 7a) ---
const STAGED_ACTIONS_LOG = `${process.env.HOME}/.claude/logs/staged-actions.ndjson`;
type StagedRun = { ts: string; name: string; action: string };
function readStagedRunsKrisp(): StagedRun[] {
if (!fs.existsSync(STAGED_ACTIONS_LOG)) return [];
const out: StagedRun[] = [];
for (const line of fs.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 withinLastDaysKrisp(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 computeKrispMetric(name: string): number | null {
const runs = readStagedRunsKrisp().filter((r) => withinLastDaysKrisp(r.ts, 7));
switch (name) {
case "inbox-per-week":
case "krisp_inbox_runs_per_week":
return runs.filter((r) => r.name === "krisp-inbox").length;
case "audit-per-week":
case "commitment_audit_runs_per_week":
return runs.filter((r) => r.name === "commitment-audit").length;
default:
return null;
}
}
/** 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-krisp",
description: "Turn Krisp meeting data into structured work for the snappy-ops chassis. Triggers on: action item extraction, meeting commitment tracking, tool-mention scouting, assignee filtering, dormant-ask detection.",
managed: true,
requires: [] as string[],
/**
* ONLY THE VERBS A CALLER CAN ACTUALLY REACH ⟨lane SONAR-JOBS, 2026-09-07⟩.
*
* This declared ten. SEVEN of them — `commitments`, `filter-assignee`,
* `meetings-since`, `pending-for`, `since`, `tool-mentions` and the `since`
* family generally — are LOCAL FILTERS that read their input from STDIN
* (`readStdin()`, the "-- local filter commands (read from stdin) --" arm
* below). Every road into a hand spawns it with no stdin: `POST /hands/run`,
* `POST /hands/stage`, `snappy_connector_action` and the approval executor all
* go through `spawnHand`, where `process.stdin.isTTY` is false and the read
* returns "". So `parsed` is null and the filter answers `[]` — SILENTLY, exit
* 0, shaped exactly like a real empty week.
*
* MEASURED, and it cost months. JOB 10 (Krisp idea cards) was armed on
* `meetings-since` and reported "MEETINGS: 0" every single day, indistinguishable
* from a quiet calendar; on 2026-09-07 a run piped `fetch-meetings` into it and
* the same window answered live for the first time since the mirror went stale
* in June. An advertised door that can only ever answer empty is worse than an
* absent one, because nobody goes looking for the absent one — which is the
* whole reason this contract exists (the same defect was fixed in snappy-gmail's
* `thread` the same day).
*
* The filters are NOT deleted: they are a shell pipeline vocabulary and the CLI
* still runs them, with their usage lines. They are simply not DOORS, so they
* are not declared as ones. The pipeline is
* `fetch-meetings <after> --limit <n> | meetings-since <iso>` and it belongs in the
* words that ask for it, never behind a verb name that implies a live read.
*/
refusals: refusalTable("unknown_verb", "missing_argument", "missing_credential", "not_found", "upstream_error"),
verbs: {
// Headless HTTP: these reach Krisp and answer rows with no stdin.
// THE COUNT IS A FLAG, NOT THE SECOND WORD (R59, measured 2026-09-08).
// `limit` used to be the second positional behind an optional date, so a
// bare `fetch-meetings 20` filed the 20 as an AFTER-DATE -- an unparseable
// day that answered a window nobody asked for. `--limit N` is the
// collection's spelling (snappy-github `repos`, snappy-update `commits`).
"fetch-meetings": {
args: ["after-date?"], effect: "read", flags: { limit: "--limit" },
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { "after-date": { type: "string", description: "ISO date; only meetings after it are returned" }, limit: { type: "integer", description: "How many meetings to return; the count is the FLAG --limit, never the second word", default: 20, maximum: 200 } } },
},
"fetch-document": {
args: ["meeting-id"], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { "meeting-id": { type: "string", description: "Meeting whose transcript document is read" } } },
},
"fetch-action-items": {
// THE FACE THIS READ DRAWS, NAMED BY THE HAND ⟨2026-09-09⟩. The runner
// used to derive it from two words — this hand's name against the faces
// manifest's family slugs, and the verb's own word against its six
// shapes — and `fetch-action-items` folds onto NONE of those shapes, so
// the whole `krisp` family had no read that reached it while this hand
// answered for it every day. The hand knows; the derivation guesses.
face: "krisp-actions",
args: ["limit?"], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { limit: { type: "integer", description: "How many action items to return", default: 50, maximum: 500 } } },
},
// Computed on this Mac from the store it already holds; no stdin either.
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 Krisp metric to compute" } } },
},
},
} 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);
}
/** THE GRAMMAR OF `fetch-meetings`, owned in ONE place so the CLI and its test
* read the same words. A leftover positional count REFUSES by name rather than
* being read as a date: a date this API cannot parse answers a window nobody
* asked for, and looks exactly like a quiet week. */
export function parseFetchMeetingsArgs(args: string[]): { after: string; limit: number; refusal?: string } {
const positional = args.filter((arg, index) => !arg.startsWith("--") && args[index - 1] !== "--limit");
const limitAt = args.indexOf("--limit");
const limit = limitAt >= 0 ? Number(args[limitAt + 1]) || 20 : 20;
const after = positional[0] || new Date(Date.now() - 86400_000).toISOString().slice(0, 10);
if (positional.length > 1) {
return { after, limit, refusal: `fetch-meetings takes one after-date; the count is a flag: fetch-meetings ${positional[0]} --limit ${positional[1]}` };
}
return { after, limit };
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
if (cmd === "metrics") {
const [name, ...rest] = args;
if (!name) {
console.error("Usage: api.ts metrics <name> [--json]");
console.error("Names: inbox-per-week, audit-per-week");
process.exit(1);
}
const value = computeKrispMetric(name);
if (rest.includes("--json")) console.log(JSON.stringify({ value }));
else console.log(value == null ? "null" : String(value));
return;
}
const raw = await readStdin();
const parsed = raw ? JSON.parse(raw) : null;
switch (cmd) {
// -- headless HTTP commands (no stdin needed) --
case "fetch-meetings": {
const { after, limit, refusal } = parseFetchMeetingsArgs(args);
if (refusal) { console.error(refusal); process.exit(1); }
json(await fetchMeetings({ after, limit, fields: ["name", "date", "attendees", "speakers", "meeting_notes"] }));
break;
}
case "fetch-document": {
const id = args[0];
if (!id) { console.error("Usage: api.ts fetch-document <meeting_id>"); process.exit(1); }
json(await fetchMeetingDocument(id));
break;
}
case "fetch-action-items": {
const limit = parseInt(args[0] || "30", 10);
// THE FACE BINDS TO WHAT THE HAND PRINTS, so the `krisp-actions` rows
// are printed BESIDE the vendor's own — `items`/`total` added, nothing
// renamed and nothing dropped, because `action_items` is the shape
// every existing reader of this verb already reads.
json(actionItemsFace(await fetchActionItems({ limit })));
break;
}
// -- local filter commands (read from stdin) --
case "filter-assignee": {
const who = args[0] || ME;
json(actionItemsFor(parsed, who));
break;
}
case "pending-for": {
const who = args[0] || ME;
json(pendingActionItemsFor(parsed, who));
break;
}
case "since": {
const iso = args[0];
if (!iso) { console.error("Usage: api.ts since <iso-date> < items.json"); process.exit(1); }
json(actionItemsSince(parsed, iso));
break;
}
case "commitments": {
const who = args[0] || ME;
json(commitmentsFrom(parsed, who));
break;
}
case "tool-mentions": {
const text = typeof parsed === "string" ? parsed : JSON.stringify(parsed);
json(extractToolMentions(text));
break;
}
case "meetings-since": {
const iso = args[0];
if (!iso) { console.error("Usage: api.ts meetings-since <iso-date> < meetings.json"); process.exit(1); }
json(meetingsSince(parsed, iso));
break;
}
default:
console.log(`Usage: npx tsx api.ts <command> [args]
Headless HTTP (no stdin):
fetch-meetings [after-date] [--limit N] Fetch meetings from Krisp API (default: last 24h, 20 rows)
fetch-document <meeting_id> Fetch full document for a meeting
fetch-action-items [limit] Fetch action items
Local filters (pipe JSON via stdin):
filter-assignee [name] Filter action items by assignee
pending-for [name] Pending items for assignee
since <iso-date> Items since date
commitments [name] Commitments from meeting notes
tool-mentions Extract tool/product mentions
meetings-since <iso-date> Meetings since date
Other:
metrics <name> [--json] Compute krisp metric (inbox-per-week, audit-per-week)
Default assignee: ${ME} (set KRISP_ME in .env.cache to override)`);
}
})();
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES, type RefusalCode } from "../snappy-settings/refusal-codes.ts";
/** RULE 33 COVERAGE, AGAINST THE ONE CLOSED TABLE ⟨lane r30, 2026-09-09⟩.
* This file used to restate a refusal row's own properties — that it has a
* `contract_slice`, that it has a `fix`, that it leaks no token — once per
* hand, 48 times, over rows that all come from the SAME object. Forty-eight
* copies of one check is the duplicate road the closed table exists to end:
* `snappy-settings/refusal-codes.test.ts` runs those checks ONCE over every
* row, and the second test below proves this hand carries THE ROW ITSELF and
* not a copy — an identity a drifted duplicate cannot fake. Before this, each
* hand hand-wrote its own row, and the wording had already drifted: the
* inline `unknown_verb` said "Call one of the verbs named in
* HAND_CONTRACT.verbs" while the closed table says "Call one of the verbs the
* contract declares; the refusal lists them."
*
* DECLARED stays a literal list, deliberately. It is this hand's coverage
* manifest and it is what rule 33's lint reads out of the test SOURCE to ask
* "did a person look at this code" — deriving it from Object.keys would make
* the test pass for a hand with no refusals at all. `satisfies readonly
* RefusalCode[]` makes the compiler refuse a name the one table does not
* have. It NAMES codes; it no longer DEFINES them. */
const DECLARED = [
"unknown_verb",
"missing_argument",
"missing_credential",
"not_found",
"upstream_error",
] as const satisfies readonly RefusalCode[];
test("snappy-krisp: the refusal table declares exactly the codes this test names", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals ?? {}).sort(), [...DECLARED].sort());
});
test("snappy-krisp: every declared refusal is the one closed table's own row, never a copy", () => {
const declared = Object.entries(HAND_CONTRACT.refusals ?? {});
assert.ok(declared.length > 0, "HAND_CONTRACT.refusals is empty");
for (const [code, row] of declared) {
assert.ok(code in REFUSAL_CODES, `${code} is not a row of snappy-settings/refusal-codes.ts`);
assert.equal(row, REFUSAL_CODES[code as RefusalCode], `${code} is a copy of the closed table's row, not the row itself`);
}
});
import { test } from "node:test";
import assert from "node:assert/strict";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES, type RefusalCode } from "../snappy-settings/refusal-codes.ts";
/** RULE 33 COVERAGE, AGAINST THE ONE CLOSED TABLE ⟨lane r30, 2026-09-09⟩.
* This file used to restate a refusal row's own properties — that it has a
* `contract_slice`, that it has a `fix`, that it leaks no token — once per
* hand, 48 times, over rows that all come from the SAME object. Forty-eight
* copies of one check is the duplicate road the closed table exists to end:
* `snappy-settings/refusal-codes.test.ts` runs those checks ONCE over every
* row, and the second test below proves this hand carries THE ROW ITSELF and
* not a copy — an identity a drifted duplicate cannot fake. Before this, each
* hand hand-wrote its own row, and the wording had already drifted: the
* inline `unknown_verb` said "Call one of the verbs named in
* HAND_CONTRACT.verbs" while the closed table says "Call one of the verbs the
* contract declares; the refusal lists them."
*
* DECLARED stays a literal list, deliberately. It is this hand's coverage
* manifest and it is what rule 33's lint reads out of the test SOURCE to ask
* "did a person look at this code" — deriving it from Object.keys would make
* the test pass for a hand with no refusals at all. `satisfies readonly
* RefusalCode[]` makes the compiler refuse a name the one table does not
* have. It NAMES codes; it no longer DEFINES them. */
const DECLARED = [
"unknown_verb",
"missing_argument",
"missing_credential",
"not_found",
"upstream_error",
] as const satisfies readonly RefusalCode[];
test("snappy-krisp: the refusal table declares exactly the codes this test names", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals ?? {}).sort(), [...DECLARED].sort());
});
test("snappy-krisp: every declared refusal is the one closed table's own row, never a copy", () => {
const declared = Object.entries(HAND_CONTRACT.refusals ?? {});
assert.ok(declared.length > 0, "HAND_CONTRACT.refusals is empty");
for (const [code, row] of declared) {
assert.ok(code in REFUSAL_CODES, `${code} is not a row of snappy-settings/refusal-codes.ts`);
assert.equal(row, REFUSAL_CODES[code as RefusalCode], `${code} is a copy of the closed table's row, not the row itself`);
}
});
{
"providers": [
{
"name": "action-items",
"label": "action item",
"description": "Robert-assigned action items from cached Krisp meetings",
"fetch": "cat ~/.claude/cache/krisp/action-items.json 2>/dev/null | npx tsx ~/.claude/skills/snappy-krisp/api.ts pending-for Robert | python3 -c \"import sys,json; raw=sys.stdin.read(); i=raw.find('['); items=json.loads(raw[i:]) if i>=0 else []; print(json.dumps([{'id':a.get('id'),'name':((a.get('title') or '')[:60]),'description':('from ' + (a.get('meeting_name') or 'meeting'))} for a in items]))\"",
"fields": { "id": "id", "label": "name", "description": "description" },
"verbs": [
{ "name": "open-meeting", "label": "open Krisp meeting", "description": "open the source meeting in Krisp UI (note: requires MCP tool)", "fire": "echo 'open meeting {id} via mcp__krisp__get_multiple_documents'" }
]
}
]
}
{
"providers": [
{
"name": "action-items",
"label": "action item",
"description": "Robert-assigned action items from cached Krisp meetings",
"fetch": "cat ~/.claude/cache/krisp/action-items.json 2>/dev/null | npx tsx ~/.claude/skills/snappy-krisp/api.ts pending-for Robert | python3 -c \"import sys,json; raw=sys.stdin.read(); i=raw.find('['); items=json.loads(raw[i:]) if i>=0 else []; print(json.dumps([{'id':a.get('id'),'name':((a.get('title') or '')[:60]),'description':('from ' + (a.get('meeting_name') or 'meeting'))} for a in items]))\"",
"fields": { "id": "id", "label": "name", "description": "description" },
"verbs": [
{ "name": "open-meeting", "label": "open Krisp meeting", "description": "open the source meeting in Krisp UI (note: requires MCP tool)", "fire": "echo 'open meeting {id} via mcp__krisp__get_multiple_documents'" }
]
}
]
}
/* components/krisp-faces.css — KRISP'S INK.
*
* Tokens at the family root, for the reason `statechange-faces.css` states:
* this face is carried into the widget, into Storybook and into a screenshot,
* and a face that paints only because some app stylesheet happened to be loaded
* around it looks broken while working perfectly ⟨owner order A9⟩.
*
* A meeting is a RECORD, so the surface is quiet and the CLOCK is the accent —
* the one thing a person scans a transcript for is where in the hour it was. */
.kr-surface {
--kr-accent: oklch(0.55 0.16 232);
--kr-ink: oklch(0.24 0.01 250);
--kr-ink-dim: oklch(0.53 0.01 250);
--kr-line: oklch(0.92 0.004 250);
--kr-ground: oklch(0.985 0.003 250);
--kr-card: oklch(1 0 0);
max-width: 640px;
border: 1px solid var(--kr-line);
border-radius: 12px;
background: var(--kr-card);
color: var(--kr-ink);
font-size: 15px;
line-height: 1.5;
overflow: hidden;
}
.kr-surface h2 { margin: 0; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; }
.kr-surface p { margin: 0; }
.kr-surface time { color: var(--kr-ink-dim); font-size: 12px; white-space: nowrap; }
.kr-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 14px 16px; border-bottom: 1px solid var(--kr-line); background: var(--kr-ground);
}
.kr-when { color: var(--kr-ink-dim); font-size: 13px; }
.kr-pill {
padding: 3px 10px; border-radius: 999px; background: oklch(0.95 0.02 232);
color: var(--kr-accent); font-size: 12px; font-weight: 600; white-space: nowrap;
}
.kr-quiet { padding: 16px; color: var(--kr-ink-dim); }
.kr-speaker {
flex: none; display: inline-flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: 50%; color: white; font-size: 11px; font-weight: 650;
}
.kr-people { display: flex; flex-wrap: wrap; gap: 8px 14px; padding: 12px 16px; border-bottom: 1px solid var(--kr-line); }
.kr-person { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; }
.kr-notes { margin: 0; padding: 12px 16px 14px 34px; }
.kr-notes li { margin: 4px 0; }
.kr-hits__list, .kr-actions__list { margin: 0; padding: 0; list-style: none; }
.kr-hits__list li { display: flex; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--kr-line); }
.kr-hits__list li:last-child, .kr-actions__list li:last-child { border-bottom: none; }
.kr-hits__body { min-width: 0; flex: 1; }
.kr-hits__meta { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; }
.kr-hits__meta strong { font-weight: 620; }
/* THE CLOCK IS THE ACCENT: it is what a person scans a transcript for. */
.kr-clock {
font-variant-numeric: tabular-nums; color: var(--kr-accent);
font-size: 12px; font-weight: 650;
}
.kr-from { color: var(--kr-ink-dim); font-size: 12px; }
.kr-hits__words { margin-top: 3px; overflow-wrap: anywhere; }
.kr-actions__list li { display: flex; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--kr-line); }
.kr-mark {
flex: none; width: 18px; height: 18px; margin-top: 2px; border-radius: 5px;
border: 1.5px solid var(--kr-line); display: inline-flex; align-items: center;
justify-content: center; font-size: 12px; color: white;
}
.kr-actions__list li[data-done="true"] .kr-mark { background: var(--kr-accent); border-color: var(--kr-accent); }
.kr-actions__list li[data-done="true"] .kr-actions__what { color: var(--kr-ink-dim); text-decoration: line-through; }
.kr-actions__what { font-weight: 560; overflow-wrap: anywhere; }
.kr-actions__where { color: var(--kr-ink-dim); font-size: 12px; margin-top: 2px; }
/* components/krisp-faces.css — KRISP'S INK.
*
* Tokens at the family root, for the reason `statechange-faces.css` states:
* this face is carried into the widget, into Storybook and into a screenshot,
* and a face that paints only because some app stylesheet happened to be loaded
* around it looks broken while working perfectly ⟨owner order A9⟩.
*
* A meeting is a RECORD, so the surface is quiet and the CLOCK is the accent —
* the one thing a person scans a transcript for is where in the hour it was. */
.kr-surface {
--kr-accent: oklch(0.55 0.16 232);
--kr-ink: oklch(0.24 0.01 250);
--kr-ink-dim: oklch(0.53 0.01 250);
--kr-line: oklch(0.92 0.004 250);
--kr-ground: oklch(0.985 0.003 250);
--kr-card: oklch(1 0 0);
max-width: 640px;
border: 1px solid var(--kr-line);
border-radius: 12px;
background: var(--kr-card);
color: var(--kr-ink);
font-size: 15px;
line-height: 1.5;
overflow: hidden;
}
.kr-surface h2 { margin: 0; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; }
.kr-surface p { margin: 0; }
.kr-surface time { color: var(--kr-ink-dim); font-size: 12px; white-space: nowrap; }
.kr-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 14px 16px; border-bottom: 1px solid var(--kr-line); background: var(--kr-ground);
}
.kr-when { color: var(--kr-ink-dim); font-size: 13px; }
.kr-pill {
padding: 3px 10px; border-radius: 999px; background: oklch(0.95 0.02 232);
color: var(--kr-accent); font-size: 12px; font-weight: 600; white-space: nowrap;
}
.kr-quiet { padding: 16px; color: var(--kr-ink-dim); }
.kr-speaker {
flex: none; display: inline-flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: 50%; color: white; font-size: 11px; font-weight: 650;
}
.kr-people { display: flex; flex-wrap: wrap; gap: 8px 14px; padding: 12px 16px; border-bottom: 1px solid var(--kr-line); }
.kr-person { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; }
.kr-notes { margin: 0; padding: 12px 16px 14px 34px; }
.kr-notes li { margin: 4px 0; }
.kr-hits__list, .kr-actions__list { margin: 0; padding: 0; list-style: none; }
.kr-hits__list li { display: flex; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--kr-line); }
.kr-hits__list li:last-child, .kr-actions__list li:last-child { border-bottom: none; }
.kr-hits__body { min-width: 0; flex: 1; }
.kr-hits__meta { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; }
.kr-hits__meta strong { font-weight: 620; }
/* THE CLOCK IS THE ACCENT: it is what a person scans a transcript for. */
.kr-clock {
font-variant-numeric: tabular-nums; color: var(--kr-accent);
font-size: 12px; font-weight: 650;
}
.kr-from { color: var(--kr-ink-dim); font-size: 12px; }
.kr-hits__words { margin-top: 3px; overflow-wrap: anywhere; }
.kr-actions__list li { display: flex; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--kr-line); }
.kr-mark {
flex: none; width: 18px; height: 18px; margin-top: 2px; border-radius: 5px;
border: 1.5px solid var(--kr-line); display: inline-flex; align-items: center;
justify-content: center; font-size: 12px; color: white;
}
.kr-actions__list li[data-done="true"] .kr-mark { background: var(--kr-accent); border-color: var(--kr-accent); }
.kr-actions__list li[data-done="true"] .kr-actions__what { color: var(--kr-ink-dim); text-decoration: line-through; }
.kr-actions__what { font-weight: 560; overflow-wrap: anywhere; }
.kr-actions__where { color: var(--kr-ink-dim); font-size: 12px; margin-top: 2px; }
// components/krisp-faces.tsx — THE MEETING, IN KRISP'S OWN LOOK.
//
// ⟨the owner, 2026-09-09 01:4x: "I want the damn internet in there"⟩
// `snappy-krisp` reads meetings, transcripts and action items off a real
// workspace, and the faces skill drew NONE of them: its answers arrived as
// prose about a meeting, which is the exact defect the Gmail faces were built
// to end. The family row for krisp was drafted by an earlier lane and REMOVED
// rather than shipped empty ⟨SKILL.md, the gap list⟩ — this file is the
// artifact that lets the row come back ⟨CLAUDE.md §10⟩.
//
// THREE SHAPES, and they are the three the hand actually answers with:
// · ONE MEETING `fetch-meetings` / `fetch-document` — when, how long,
// who was in it, and what it decided.
// · THE PASSAGES `search_meeting_content` — a hit is a SPEAKER at a
// CLOCK TIME saying words, in the meeting it came from.
// That is not the same row as a corpus hit (a document
// and a position), which is why `transcript-faces.tsx`
// is a different family and not this one reused.
// · THE ACTION ITEMS `fetch-action-items` — who owes what, out of which
// meeting, and whether it is closed.
//
// NO SUMMARY IS INVENTED. A meeting the hand read without a summary draws its
// facts and says nothing about what it meant; a face that fills that silence
// with a generated sentence is the fabrication this product exists to make
// unrepresentable.
//
// THE INK is `krisp-faces.css` and declares its own tokens at the family root,
// exactly as `statechange-faces.css` does: this face must read the same under
// the widget, Storybook and a screenshot with no app stylesheet around it.
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod";
import type { JSX } from "react";
import { PersonAvatar } from "../../../snappy-faces/library/src/components/person.tsx";
import { durationWords } from "../../../snappy-faces/library/src/components/social-card-format.ts";
import { rowPressProps } from "../../../snappy-faces/library/src/components/row-press.tsx";
import "./krisp-faces.css";
// ── ONE MEETING ─────────────────────────────────────────────────────────────
export interface KrispMeetingViewProps {
readonly title: string;
readonly startedAt?: string | null;
readonly durationSeconds?: number | null;
readonly attendees?: readonly string[] | null;
/** What the meeting decided, as the workspace recorded it — one bullet per
* line, never generated here. */
readonly notes?: readonly string[] | null;
readonly source?: string | null;
}
export function KrispMeetingView(props: KrispMeetingViewProps): JSX.Element {
const attendees = props.attendees ?? [];
const notes = props.notes ?? [];
return (
<article className="kr-surface kr-meeting" aria-label={props.title}>
<header className="kr-head">
<div>
<h2>{props.title}</h2>
<p className="kr-when">
{[props.startedAt, typeof props.durationSeconds === "number" ? durationWords(props.durationSeconds) : null]
.filter((word) => word != null && word !== "").join(" · ")}
</p>
</div>
<span className="kr-pill">{props.source ?? "Krisp"}</span>
</header>
{attendees.length === 0 ? null : (
<div className="kr-people">
{attendees.map((name) => (
<span key={name} className="kr-person"><PersonAvatar name={name} className="kr-speaker" />{name}</span>
))}
</div>
)}
{notes.length === 0
? <p className="kr-quiet">The workspace recorded no notes for this meeting.</p>
: <ul className="kr-notes">{notes.map((note, i) => <li key={i}>{note}</li>)}</ul>}
</article>
);
}
// ── THE PASSAGES THAT MATCHED ───────────────────────────────────────────────
export interface KrispHitRow {
readonly speaker: string;
readonly words: string;
/** The clock inside the recording — `12:04`, the spelling the player shows. */
readonly at?: string | null;
readonly meeting?: string | null;
/** THE MEETING'S OWN ID — Krisp's `meeting_id`, which `fetch-document
* <meeting-id>` takes. `meeting` is its TITLE and addresses nothing, so
* without this a hit could not open the conversation it came from. */
readonly meetingId?: string | null;
readonly meetingAt?: string | null;
}
export interface KrispTranscriptHitsViewProps {
readonly hits?: readonly KrispHitRow[];
readonly query?: string | null;
readonly total?: number | null;
/** TWENTY, NOT THREE ⟨the owner, 2026-09-09 01:5x⟩. */
readonly clampAt?: number;
}
export function KrispTranscriptHitsView(props: KrispTranscriptHitsViewProps): JSX.Element {
const hits = (props.hits ?? []).slice(0, props.clampAt ?? 20);
const total = props.total ?? hits.length;
return (
<section className="kr-surface kr-hits" aria-label={props.query ?? "Transcript hits"}>
<header className="kr-head">
<div>
<h2>{props.query == null ? "In the transcripts" : `“${props.query}”`}</h2>
<p className="kr-when">{total} {total === 1 ? "passage" : "passages"}</p>
</div>
<span className="kr-pill">Krisp</span>
</header>
{hits.length === 0
? <p className="kr-quiet">Nothing in the transcripts said this.</p>
: <ul className="kr-hits__list">
{hits.map((hit, i) => (
// THE PASSAGE OPENS ITS MEETING ⟨lane list-rows, 2026-09-09⟩:
// `snappy-krisp fetch-document meeting-id`, a READ, drawn as the
// `krisp-meeting` face.
<li key={i} {...rowPressProps("krisp-hits", hit as unknown as Record<string, unknown>)}>
<PersonAvatar name={hit.speaker} className="kr-speaker" />
<div className="kr-hits__body">
<div className="kr-hits__meta">
<strong>{hit.speaker}</strong>
{hit.at == null ? null : <span className="kr-clock">{hit.at}</span>}
{hit.meeting == null ? null : <span className="kr-from">{hit.meeting}</span>}
{hit.meetingAt == null ? null : <time>{hit.meetingAt}</time>}
</div>
<p className="kr-hits__words">{hit.words}</p>
</div>
</li>
))}
</ul>}
</section>
);
}
// ── WHO OWES WHAT ───────────────────────────────────────────────────────────
export interface KrispActionRow {
readonly what: string;
readonly owner?: string | null;
readonly meeting?: string | null;
readonly due?: string | null;
readonly done?: boolean | null;
}
export interface KrispActionItemsViewProps {
readonly items?: readonly KrispActionRow[];
readonly total?: number | null;
readonly clampAt?: number;
}
export function KrispActionItemsView(props: KrispActionItemsViewProps): JSX.Element {
const items = (props.items ?? []).slice(0, props.clampAt ?? 20);
const open = items.filter((item) => item.done !== true).length;
return (
<section className="kr-surface kr-actions" aria-label="Action items">
<header className="kr-head">
<div>
<h2>Action items</h2>
<p className="kr-when">{open} still open of {props.total ?? items.length}</p>
</div>
<span className="kr-pill">Krisp</span>
</header>
{items.length === 0
? <p className="kr-quiet">No action items came out of these meetings.</p>
: <ul className="kr-actions__list">
{items.map((item, i) => (
<li key={i} data-done={item.done === true ? "true" : "false"}>
<span className="kr-mark" aria-hidden="true">{item.done === true ? "✓" : ""}</span>
<div>
<p className="kr-actions__what">{item.what}</p>
<p className="kr-actions__where">
{[item.owner, item.meeting, item.due == null ? null : `due ${item.due}`]
.filter((word) => word != null && word !== "").join(" · ")}
</p>
</div>
</li>
))}
</ul>}
</section>
);
}
// ── THE REGISTRATIONS. Prop order IS the positional argument order Lang binds;
// appended, never inserted.
export const KrispMeetingComponent = defineComponent({
name: "KrispMeeting",
description: "USE FOR: 'what happened in that meeting', 'show me the standup', any ONE meeting Krisp recorded. Draws the meeting as its own object — title, when it started, how long it ran, who was in it, and the notes the workspace recorded. Compact call: KrispMeeting(title). Positional after that: startedAt, durationSeconds (measured seconds — never estimate), attendees (array of names), notes (array of lines the workspace recorded — NEVER a summary you wrote), source. With no notes the face says the workspace recorded none rather than inventing what the meeting decided.",
props: z.object({
title: z.string(),
startedAt: z.string().nullish(),
durationSeconds: z.number().nullish(),
attendees: z.array(z.string()).nullish(),
notes: z.array(z.string()).nullish(),
source: z.string().nullish(),
}),
component: ({ props }): JSX.Element => (
<KrispMeetingView
title={props.title} startedAt={props.startedAt} durationSeconds={props.durationSeconds}
attendees={props.attendees} notes={props.notes} source={props.source}
/>
),
});
export const KrispTranscriptHitsComponent = defineComponent({
name: "KrispTranscriptHits",
description: "USE FOR: 'when did we talk about the fallback', 'search the meetings for pricing', any search across Krisp transcripts. Draws each hit as what it is — a SPEAKER at a CLOCK TIME inside a named meeting, saying words — twenty by default. Compact call: KrispTranscriptHits(hits, query) where hits is [{speaker, words, at?, meeting?, meetingId?, meetingAt?}]. PASS `meetingId` — Krisp's own meeting_id, which every search row carries: with it a hit OPENS, running `snappy-krisp fetch-document <meeting-id>` and drawing the meeting as KrispMeeting. Positional after that: total (how many the workspace matched, when more exist than were returned). For a passage out of a document rather than a meeting use TranscriptHits.",
props: z.object({
hits: z.array(z.object({
speaker: z.string(), words: z.string(), at: z.string().nullish(),
meeting: z.string().nullish(), meetingId: z.string().nullish(), meetingAt: z.string().nullish(),
})).nullish(),
query: z.string().nullish(),
total: z.number().nullish(),
}),
component: ({ props }): JSX.Element => (
<KrispTranscriptHitsView hits={props.hits ?? undefined} query={props.query} total={props.total} />
),
});
export const KrispActionItemsComponent = defineComponent({
name: "KrispActionItems",
description: "USE FOR: 'what did I agree to', 'what came out of the meetings', the open action items Krisp captured. Draws who owes what, out of which meeting, with the closed ones struck. Compact call: KrispActionItems(items) where items is [{what, owner?, meeting?, due?, done?}]. Positional after that: total. The header counts what is STILL OPEN, because that is the number a person is looking for.",
props: z.object({
items: z.array(z.object({
what: z.string(), owner: z.string().nullish(), meeting: z.string().nullish(),
due: z.string().nullish(), done: z.boolean().nullish(),
})).nullish(),
total: z.number().nullish(),
}),
component: ({ props }): JSX.Element => (
<KrispActionItemsView items={props.items ?? undefined} total={props.total} />
),
});
// components/krisp-faces.tsx — THE MEETING, IN KRISP'S OWN LOOK.
//
// ⟨the owner, 2026-09-09 01:4x: "I want the damn internet in there"⟩
// `snappy-krisp` reads meetings, transcripts and action items off a real
// workspace, and the faces skill drew NONE of them: its answers arrived as
// prose about a meeting, which is the exact defect the Gmail faces were built
// to end. The family row for krisp was drafted by an earlier lane and REMOVED
// rather than shipped empty ⟨SKILL.md, the gap list⟩ — this file is the
// artifact that lets the row come back ⟨CLAUDE.md §10⟩.
//
// THREE SHAPES, and they are the three the hand actually answers with:
// · ONE MEETING `fetch-meetings` / `fetch-document` — when, how long,
// who was in it, and what it decided.
// · THE PASSAGES `search_meeting_content` — a hit is a SPEAKER at a
// CLOCK TIME saying words, in the meeting it came from.
// That is not the same row as a corpus hit (a document
// and a position), which is why `transcript-faces.tsx`
// is a different family and not this one reused.
// · THE ACTION ITEMS `fetch-action-items` — who owes what, out of which
// meeting, and whether it is closed.
//
// NO SUMMARY IS INVENTED. A meeting the hand read without a summary draws its
// facts and says nothing about what it meant; a face that fills that silence
// with a generated sentence is the fabrication this product exists to make
// unrepresentable.
//
// THE INK is `krisp-faces.css` and declares its own tokens at the family root,
// exactly as `statechange-faces.css` does: this face must read the same under
// the widget, Storybook and a screenshot with no app stylesheet around it.
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod";
import type { JSX } from "react";
import { PersonAvatar } from "../../../snappy-faces/library/src/components/person.tsx";
import { durationWords } from "../../../snappy-faces/library/src/components/social-card-format.ts";
import { rowPressProps } from "../../../snappy-faces/library/src/components/row-press.tsx";
import "./krisp-faces.css";
// ── ONE MEETING ─────────────────────────────────────────────────────────────
export interface KrispMeetingViewProps {
readonly title: string;
readonly startedAt?: string | null;
readonly durationSeconds?: number | null;
readonly attendees?: readonly string[] | null;
/** What the meeting decided, as the workspace recorded it — one bullet per
* line, never generated here. */
readonly notes?: readonly string[] | null;
readonly source?: string | null;
}
export function KrispMeetingView(props: KrispMeetingViewProps): JSX.Element {
const attendees = props.attendees ?? [];
const notes = props.notes ?? [];
return (
<article className="kr-surface kr-meeting" aria-label={props.title}>
<header className="kr-head">
<div>
<h2>{props.title}</h2>
<p className="kr-when">
{[props.startedAt, typeof props.durationSeconds === "number" ? durationWords(props.durationSeconds) : null]
.filter((word) => word != null && word !== "").join(" · ")}
</p>
</div>
<span className="kr-pill">{props.source ?? "Krisp"}</span>
</header>
{attendees.length === 0 ? null : (
<div className="kr-people">
{attendees.map((name) => (
<span key={name} className="kr-person"><PersonAvatar name={name} className="kr-speaker" />{name}</span>
))}
</div>
)}
{notes.length === 0
? <p className="kr-quiet">The workspace recorded no notes for this meeting.</p>
: <ul className="kr-notes">{notes.map((note, i) => <li key={i}>{note}</li>)}</ul>}
</article>
);
}
// ── THE PASSAGES THAT MATCHED ───────────────────────────────────────────────
export interface KrispHitRow {
readonly speaker: string;
readonly words: string;
/** The clock inside the recording — `12:04`, the spelling the player shows. */
readonly at?: string | null;
readonly meeting?: string | null;
/** THE MEETING'S OWN ID — Krisp's `meeting_id`, which `fetch-document
* <meeting-id>` takes. `meeting` is its TITLE and addresses nothing, so
* without this a hit could not open the conversation it came from. */
readonly meetingId?: string | null;
readonly meetingAt?: string | null;
}
export interface KrispTranscriptHitsViewProps {
readonly hits?: readonly KrispHitRow[];
readonly query?: string | null;
readonly total?: number | null;
/** TWENTY, NOT THREE ⟨the owner, 2026-09-09 01:5x⟩. */
readonly clampAt?: number;
}
export function KrispTranscriptHitsView(props: KrispTranscriptHitsViewProps): JSX.Element {
const hits = (props.hits ?? []).slice(0, props.clampAt ?? 20);
const total = props.total ?? hits.length;
return (
<section className="kr-surface kr-hits" aria-label={props.query ?? "Transcript hits"}>
<header className="kr-head">
<div>
<h2>{props.query == null ? "In the transcripts" : `“${props.query}”`}</h2>
<p className="kr-when">{total} {total === 1 ? "passage" : "passages"}</p>
</div>
<span className="kr-pill">Krisp</span>
</header>
{hits.length === 0
? <p className="kr-quiet">Nothing in the transcripts said this.</p>
: <ul className="kr-hits__list">
{hits.map((hit, i) => (
// THE PASSAGE OPENS ITS MEETING ⟨lane list-rows, 2026-09-09⟩:
// `snappy-krisp fetch-document meeting-id`, a READ, drawn as the
// `krisp-meeting` face.
<li key={i} {...rowPressProps("krisp-hits", hit as unknown as Record<string, unknown>)}>
<PersonAvatar name={hit.speaker} className="kr-speaker" />
<div className="kr-hits__body">
<div className="kr-hits__meta">
<strong>{hit.speaker}</strong>
{hit.at == null ? null : <span className="kr-clock">{hit.at}</span>}
{hit.meeting == null ? null : <span className="kr-from">{hit.meeting}</span>}
{hit.meetingAt == null ? null : <time>{hit.meetingAt}</time>}
</div>
<p className="kr-hits__words">{hit.words}</p>
</div>
</li>
))}
</ul>}
</section>
);
}
// ── WHO OWES WHAT ───────────────────────────────────────────────────────────
export interface KrispActionRow {
readonly what: string;
readonly owner?: string | null;
readonly meeting?: string | null;
readonly due?: string | null;
readonly done?: boolean | null;
}
export interface KrispActionItemsViewProps {
readonly items?: readonly KrispActionRow[];
readonly total?: number | null;
readonly clampAt?: number;
}
export function KrispActionItemsView(props: KrispActionItemsViewProps): JSX.Element {
const items = (props.items ?? []).slice(0, props.clampAt ?? 20);
const open = items.filter((item) => item.done !== true).length;
return (
<section className="kr-surface kr-actions" aria-label="Action items">
<header className="kr-head">
<div>
<h2>Action items</h2>
<p className="kr-when">{open} still open of {props.total ?? items.length}</p>
</div>
<span className="kr-pill">Krisp</span>
</header>
{items.length === 0
? <p className="kr-quiet">No action items came out of these meetings.</p>
: <ul className="kr-actions__list">
{items.map((item, i) => (
<li key={i} data-done={item.done === true ? "true" : "false"}>
<span className="kr-mark" aria-hidden="true">{item.done === true ? "✓" : ""}</span>
<div>
<p className="kr-actions__what">{item.what}</p>
<p className="kr-actions__where">
{[item.owner, item.meeting, item.due == null ? null : `due ${item.due}`]
.filter((word) => word != null && word !== "").join(" · ")}
</p>
</div>
</li>
))}
</ul>}
</section>
);
}
// ── THE REGISTRATIONS. Prop order IS the positional argument order Lang binds;
// appended, never inserted.
export const KrispMeetingComponent = defineComponent({
name: "KrispMeeting",
description: "USE FOR: 'what happened in that meeting', 'show me the standup', any ONE meeting Krisp recorded. Draws the meeting as its own object — title, when it started, how long it ran, who was in it, and the notes the workspace recorded. Compact call: KrispMeeting(title). Positional after that: startedAt, durationSeconds (measured seconds — never estimate), attendees (array of names), notes (array of lines the workspace recorded — NEVER a summary you wrote), source. With no notes the face says the workspace recorded none rather than inventing what the meeting decided.",
props: z.object({
title: z.string(),
startedAt: z.string().nullish(),
durationSeconds: z.number().nullish(),
attendees: z.array(z.string()).nullish(),
notes: z.array(z.string()).nullish(),
source: z.string().nullish(),
}),
component: ({ props }): JSX.Element => (
<KrispMeetingView
title={props.title} startedAt={props.startedAt} durationSeconds={props.durationSeconds}
attendees={props.attendees} notes={props.notes} source={props.source}
/>
),
});
export const KrispTranscriptHitsComponent = defineComponent({
name: "KrispTranscriptHits",
description: "USE FOR: 'when did we talk about the fallback', 'search the meetings for pricing', any search across Krisp transcripts. Draws each hit as what it is — a SPEAKER at a CLOCK TIME inside a named meeting, saying words — twenty by default. Compact call: KrispTranscriptHits(hits, query) where hits is [{speaker, words, at?, meeting?, meetingId?, meetingAt?}]. PASS `meetingId` — Krisp's own meeting_id, which every search row carries: with it a hit OPENS, running `snappy-krisp fetch-document <meeting-id>` and drawing the meeting as KrispMeeting. Positional after that: total (how many the workspace matched, when more exist than were returned). For a passage out of a document rather than a meeting use TranscriptHits.",
props: z.object({
hits: z.array(z.object({
speaker: z.string(), words: z.string(), at: z.string().nullish(),
meeting: z.string().nullish(), meetingId: z.string().nullish(), meetingAt: z.string().nullish(),
})).nullish(),
query: z.string().nullish(),
total: z.number().nullish(),
}),
component: ({ props }): JSX.Element => (
<KrispTranscriptHitsView hits={props.hits ?? undefined} query={props.query} total={props.total} />
),
});
export const KrispActionItemsComponent = defineComponent({
name: "KrispActionItems",
description: "USE FOR: 'what did I agree to', 'what came out of the meetings', the open action items Krisp captured. Draws who owes what, out of which meeting, with the closed ones struck. Compact call: KrispActionItems(items) where items is [{what, owner?, meeting?, due?, done?}]. Positional after that: total. The header counts what is STILL OPEN, because that is the number a person is looking for.",
props: z.object({
items: z.array(z.object({
what: z.string(), owner: z.string().nullish(), meeting: z.string().nullish(),
due: z.string().nullish(), done: z.boolean().nullish(),
})).nullish(),
total: z.number().nullish(),
}),
component: ({ props }): JSX.Element => (
<KrispActionItemsView items={props.items ?? undefined} total={props.total} />
),
});
/** families/krisp.tsx — THE KRISP FAMILY, as its own chunk.
*
* Fetched from `ui://snappy/faces/krisp.js` the first time a krisp face is
* drawn, and never before ⟨`face-family.ts`⟩. Every mount forwards the payload
* to the view unchanged — the same one `createElement` the core applies to all
* of them, so a per-face arm here would restate a forwarding that already
* exists beside the component. */
import type { FaceFamilyModule } from "../../snappy-faces/face-family.ts";
import {
KrispActionItemsView, KrispMeetingView, KrispTranscriptHitsView,
} from "./components/krisp-faces.tsx";
export const FAMILY: FaceFamilyModule = {
slug: "krisp",
mounts: {
"krisp-meeting": KrispMeetingView,
"krisp-hits": KrispTranscriptHitsView,
"krisp-actions": KrispActionItemsView,
},
};
/** families/krisp.tsx — THE KRISP FAMILY, as its own chunk.
*
* Fetched from `ui://snappy/faces/krisp.js` the first time a krisp face is
* drawn, and never before ⟨`face-family.ts`⟩. Every mount forwards the payload
* to the view unchanged — the same one `createElement` the core applies to all
* of them, so a per-face arm here would restate a forwarding that already
* exists beside the component. */
import type { FaceFamilyModule } from "../../snappy-faces/face-family.ts";
import {
KrispActionItemsView, KrispMeetingView, KrispTranscriptHitsView,
} from "./components/krisp-faces.tsx";
export const FAMILY: FaceFamilyModule = {
slug: "krisp",
mounts: {
"krisp-meeting": KrispMeetingView,
"krisp-hits": KrispTranscriptHitsView,
"krisp-actions": KrispActionItemsView,
},
};
{
"total": 5,
"items": [
{ "what": "Post the launch sequence to Harbourline before the import job starts", "owner": "Mara Quill", "meeting": "Northstar rollout — go / no-go", "due": "Wed" },
{ "what": "Own the fallback if the import job runs long", "owner": "Mara Quill", "meeting": "Northstar rollout — go / no-go", "due": "Thu" },
{ "what": "Re-circulate the brief with the closed checklist", "owner": "Nadia Brandt", "meeting": "Northstar rollout — go / no-go", "due": "Wed" },
{ "what": "Name an owner for the rollback message", "meeting": "Northstar rollout — go / no-go" },
{ "what": "Time the import job end to end once, cold", "owner": "Tom Ferreira", "meeting": "Harbourline weekly", "due": "Sep 5", "done": true }
]
}
{
"total": 5,
"items": [
{ "what": "Post the launch sequence to Harbourline before the import job starts", "owner": "Mara Quill", "meeting": "Northstar rollout — go / no-go", "due": "Wed" },
{ "what": "Own the fallback if the import job runs long", "owner": "Mara Quill", "meeting": "Northstar rollout — go / no-go", "due": "Thu" },
{ "what": "Re-circulate the brief with the closed checklist", "owner": "Nadia Brandt", "meeting": "Northstar rollout — go / no-go", "due": "Wed" },
{ "what": "Name an owner for the rollback message", "meeting": "Northstar rollout — go / no-go" },
{ "what": "Time the import job end to end once, cold", "owner": "Tom Ferreira", "meeting": "Harbourline weekly", "due": "Sep 5", "done": true }
]
}
{
"query": "the fallback",
"total": 6,
"hits": [
{
"speaker": "Mara Quill",
"at": "12:04",
"meeting": "Northstar rollout — go / no-go",
"meetingAt": "Sep 8",
"words": "The fallback fires on the row, not the file. That was the change — before it, you got a filename and no idea which of forty thousand rows had gone wrong.",
"meetingId": "mtg_9f2c41"
},
{
"speaker": "Priya Raman",
"at": "18:41",
"meeting": "Northstar rollout — go / no-go",
"meetingAt": "Sep 8",
"words": "Who owns it when it fires during a rollout, though? That is the part nobody has answered in three weeks.",
"meetingId": "mtg_9f2c41"
},
{
"speaker": "Mara Quill",
"at": "19:02",
"meeting": "Northstar rollout — go / no-go",
"meetingAt": "Sep 8",
"words": "I will take it unless someone objects by Thursday.",
"meetingId": "mtg_9f2c41"
},
{
"speaker": "Nadia Brandt",
"at": "07:55",
"meeting": "Harbourline weekly",
"meetingAt": "Sep 4",
"words": "We should say in the brief that the fallback is expected to fire on the first pass. People read a fired fallback as a failure.",
"meetingId": "mtg_7b0d18"
}
]
}
{
"query": "the fallback",
"total": 6,
"hits": [
{
"speaker": "Mara Quill",
"at": "12:04",
"meeting": "Northstar rollout — go / no-go",
"meetingAt": "Sep 8",
"words": "The fallback fires on the row, not the file. That was the change — before it, you got a filename and no idea which of forty thousand rows had gone wrong.",
"meetingId": "mtg_9f2c41"
},
{
"speaker": "Priya Raman",
"at": "18:41",
"meeting": "Northstar rollout — go / no-go",
"meetingAt": "Sep 8",
"words": "Who owns it when it fires during a rollout, though? That is the part nobody has answered in three weeks.",
"meetingId": "mtg_9f2c41"
},
{
"speaker": "Mara Quill",
"at": "19:02",
"meeting": "Northstar rollout — go / no-go",
"meetingAt": "Sep 8",
"words": "I will take it unless someone objects by Thursday.",
"meetingId": "mtg_9f2c41"
},
{
"speaker": "Nadia Brandt",
"at": "07:55",
"meeting": "Harbourline weekly",
"meetingAt": "Sep 4",
"words": "We should say in the brief that the fallback is expected to fire on the first pass. People read a fired fallback as a failure.",
"meetingId": "mtg_7b0d18"
}
]
}
{
"title": "Northstar rollout — go / no-go",
"startedAt": "Sep 8, 9:30 AM",
"durationSeconds": 2745,
"attendees": ["Mara Quill", "Priya Raman", "Nadia Brandt", "Tom Ferreira"],
"notes": [
"Start day moves to Thursday: the import job has not yet run end to end under an hour.",
"Mara owns the fallback if the job runs long; she posts the sequence before it starts.",
"Nadia closes the remaining checklist items and re-circulates the brief on Wednesday.",
"Open: nobody owns the rollback message to the Harbourline space."
],
"source": "Krisp · Quillworks"
}
{
"title": "Northstar rollout — go / no-go",
"startedAt": "Sep 8, 9:30 AM",
"durationSeconds": 2745,
"attendees": ["Mara Quill", "Priya Raman", "Nadia Brandt", "Tom Ferreira"],
"notes": [
"Start day moves to Thursday: the import job has not yet run end to end under an hour.",
"Mara owns the fallback if the job runs long; she posts the sequence before it starts.",
"Nadia closes the remaining checklist items and re-circulates the brief on Wednesday.",
"Open: nobody owns the rollback message to the Harbourline space."
],
"source": "Krisp · Quillworks"
}
{
"_comment": "Per-skill quality gauges for snappy-krisp. Driven by staged-actions.ndjson krisp-inbox + commitment-audit recipe runs.",
"metrics": [
{
"name": "krisp_inbox_runs_per_week",
"label": "krisp-inbox runs / week",
"description": "krisp-inbox recipe runs in the last 7d",
"fetch": "npx tsx ~/.claude/skills/snappy-krisp/api.ts metrics inbox-per-week --json",
"direction": "higher_is_better",
"format": "number",
"target": 7
},
{
"name": "commitment_audit_runs_per_week",
"label": "commitment-audit / week",
"description": "commitment-audit recipe runs in the last 7d",
"fetch": "npx tsx ~/.claude/skills/snappy-krisp/api.ts metrics audit-per-week --json",
"direction": "higher_is_better",
"format": "number",
"target": 1
}
],
"tests": [
{
"name": "smoke",
"label": "compute both metrics without throwing",
"fire": "npx tsx ~/.claude/skills/snappy-krisp/api.ts metrics inbox-per-week --json && npx tsx ~/.claude/skills/snappy-krisp/api.ts metrics audit-per-week --json"
}
]
}
{
"_comment": "Per-skill quality gauges for snappy-krisp. Driven by staged-actions.ndjson krisp-inbox + commitment-audit recipe runs.",
"metrics": [
{
"name": "krisp_inbox_runs_per_week",
"label": "krisp-inbox runs / week",
"description": "krisp-inbox recipe runs in the last 7d",
"fetch": "npx tsx ~/.claude/skills/snappy-krisp/api.ts metrics inbox-per-week --json",
"direction": "higher_is_better",
"format": "number",
"target": 7
},
{
"name": "commitment_audit_runs_per_week",
"label": "commitment-audit / week",
"description": "commitment-audit recipe runs in the last 7d",
"fetch": "npx tsx ~/.claude/skills/snappy-krisp/api.ts metrics audit-per-week --json",
"direction": "higher_is_better",
"format": "number",
"target": 1
}
],
"tests": [
{
"name": "smoke",
"label": "compute both metrics without throwing",
"fire": "npx tsx ~/.claude/skills/snappy-krisp/api.ts metrics inbox-per-week --json && npx tsx ~/.claude/skills/snappy-krisp/api.ts metrics audit-per-week --json"
}
]
}