feat(kon): scaffold hybrid modular workspace

- Cargo workspace with 6 domain crates: core, audio, transcription, ai-formatting, storage, cloud-providers
- Minimal Tauri shell (lib.rs + main.rs) with plugin registration
- Svelte 5 frontend copied from Ramble v0.2
- All crates compile as empty stubs
- App identifier: uk.co.corbel.kon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-16 20:21:38 +00:00
commit 9926a42b7a
80 changed files with 13328 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
// Timing
export const FEEDBACK_TIMEOUT_MS = 3000;
export const HOTKEY_FEEDBACK_MS = 1500;
export const HISTORY_MAX_ENTRIES = 100;
export const MAX_PCM_SAMPLES = 4_800_000; // 5 min at 16kHz
export const SIDEBAR_MAX_TASKS = 30;
export const CHUNK_INTERVAL_MS = 3000;
export const MIN_CHUNK_SAMPLES = 8000;
// Buckets
export const BUCKET_COLORS = {
inbox: "text-text-tertiary",
today: "text-accent",
soon: "text-warning",
later: "text-text-secondary",
};
export const BUCKET_DOT_COLORS = {
inbox: "bg-text-tertiary",
today: "bg-accent",
soon: "bg-warning",
later: "bg-text-secondary",
};
// Effort
export const EFFORT_LABELS = { quick: "Quick", medium: "Medium", deep: "Deep" };
export const EFFORT_ORDER = { quick: 1, medium: 2, deep: 3, "": 4 };
// Playback
export const PLAYBACK_SPEEDS = [0.5, 1, 1.5, 2, 3, 5];

116
src/lib/utils/export.js Normal file
View File

@@ -0,0 +1,116 @@
import { pad, formatTimeSRT, formatTimeVTT } from "./time.js";
/**
* Export transcript in various formats.
* @param {string} text - Plain text transcript
* @param {Array<{start: number, end: number, text: string}>} segments - Timed segments
* @param {string} format - "txt" | "md" | "csv" | "html" | "srt" | "vtt"
* @returns {string}
*/
export function exportTranscript(text, segments, format) {
switch (format) {
case "srt":
return toSRT(segments);
case "vtt":
return toVTT(segments);
case "md":
return toMarkdown(text, segments);
case "csv":
return toCSV(segments);
case "html":
return toHTML(text, segments);
default:
return text;
}
}
function toSRT(segments) {
if (!segments || segments.length === 0) return "";
return segments
.map((seg, i) => {
return `${i + 1}\n${formatTimeSRT(seg.start)} --> ${formatTimeSRT(seg.end)}\n${seg.text.trim()}\n`;
})
.join("\n");
}
function toVTT(segments) {
if (!segments || segments.length === 0) return "";
let out = "WEBVTT\n\n";
out += segments
.map((seg, i) => {
return `${i + 1}\n${formatTimeVTT(seg.start)} --> ${formatTimeVTT(seg.end)}\n${seg.text.trim()}\n`;
})
.join("\n");
return out;
}
function toCSV(segments) {
if (!segments || segments.length === 0) return "Start,End,Text\n";
let csv = "Start,End,Text\n";
for (const seg of segments) {
const text = seg.text.trim().replace(/"/g, '""');
csv += `${seg.start.toFixed(2)},${seg.end.toFixed(2)},"${text}"\n`;
}
return csv;
}
function toHTML(text, segments) {
let html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ramble Transcript</title>
<style>
body { font-family: "DM Sans", system-ui, sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; color: #1a1816; line-height: 1.7; }
h1 { font-family: "Instrument Serif", Georgia, serif; font-style: italic; }
.meta { color: #9a9486; font-size: 0.85rem; margin-bottom: 1.5rem; }
.segment { display: flex; gap: 1rem; padding: 0.4rem 0; }
.timestamp { color: #9a9486; font-size: 0.8rem; min-width: 3rem; font-variant-numeric: tabular-nums; padding-top: 0.15rem; }
.text { flex: 1; }
.starred { border-left: 3px solid #e8a87c; padding-left: 0.5rem; }
</style>
</head>
<body>
<h1>Transcript</h1>
<p class="meta">${new Date().toLocaleDateString("en-GB")}`;
if (segments?.length > 0) {
const dur = segments[segments.length - 1].end;
html += ` &middot; ${Math.round(dur)}s`;
}
html += `</p>\n`;
if (segments?.length > 0) {
for (const seg of segments) {
const mins = Math.floor(seg.start / 60);
const secs = Math.floor(seg.start % 60);
const starClass = seg.starred ? ' starred' : '';
html += `<div class="segment${starClass}"><span class="timestamp">${pad(mins)}:${pad(secs)}</span><span class="text">${seg.text.trim()}</span></div>\n`;
}
} else {
html += `<p>${text.replace(/\n/g, "<br>")}</p>`;
}
html += `</body></html>`;
return html;
}
function toMarkdown(text, segments) {
let md = `# Transcription\n\n`;
md += `**Date:** ${new Date().toLocaleDateString("en-GB")}\n`;
if (segments && segments.length > 0) {
const duration = segments[segments.length - 1].end;
md += `**Duration:** ${Math.round(duration)}s\n`;
}
md += `\n---\n\n`;
if (segments && segments.length > 0) {
segments.forEach((seg) => {
const mins = Math.floor(seg.start / 60);
const secs = Math.floor(seg.start % 60);
md += `[${pad(mins)}:${pad(secs)}] ${seg.text.trim()}\n\n`;
});
} else {
md += text;
}
return md;
}

View File

@@ -0,0 +1,127 @@
/**
* Rule-based task extraction from transcripts.
* Identifies action items from natural speech patterns.
*/
// Action verbs that typically start task sentences
const ACTION_VERBS = [
"call", "email", "send", "write", "build", "fix", "update", "check",
"review", "schedule", "book", "create", "set up", "follow up", "organise",
"prepare", "draft", "submit", "cancel", "confirm", "arrange", "order",
"buy", "get", "find", "look into", "research", "test", "deploy", "push",
"move", "add", "remove", "delete", "install", "configure", "contact",
"message", "tell", "ask", "invite", "remind", "finish", "complete",
"start", "begin", "plan", "design", "implement", "refactor",
];
// Phrases that signal a task
const TASK_PHRASES = [
"need to", "needs to", "should", "must", "have to", "has to",
"want to", "going to", "gonna", "got to", "gotta",
"don't forget to", "remember to", "make sure to", "make sure we",
"let's", "we should", "i should", "we need to", "i need to",
"we have to", "i have to", "we must", "i must",
];
// Explicit markers (user intentionally marks a task)
const EXPLICIT_MARKERS = [
"action:", "action item:", "todo:", "task:", "to do:",
"action item", "follow up:", "follow-up:",
];
/**
* Extract tasks from a transcript string.
* Returns an array of { text, confidence } objects.
*/
export function extractTasks(transcript) {
if (!transcript || !transcript.trim()) return [];
const tasks = [];
const seen = new Set();
// Split into sentences (rough but effective for speech)
const sentences = splitSentences(transcript);
for (const sentence of sentences) {
const trimmed = sentence.trim();
if (trimmed.length < 5) continue;
const lower = trimmed.toLowerCase();
let matched = false;
let confidence = 0;
// Check explicit markers (highest confidence)
for (const marker of EXPLICIT_MARKERS) {
if (lower.startsWith(marker) || lower.includes(marker)) {
const taskText = extractAfterMarker(trimmed, marker);
if (taskText && taskText.length > 3) {
addTask(tasks, seen, capitalise(taskText), 0.95);
matched = true;
break;
}
}
}
if (matched) continue;
// Check task phrases (high confidence)
for (const phrase of TASK_PHRASES) {
const idx = lower.indexOf(phrase);
if (idx >= 0) {
const taskText = extractTaskFromPhrase(trimmed, phrase, idx);
if (taskText && taskText.length > 3) {
addTask(tasks, seen, capitalise(taskText), 0.8);
matched = true;
break;
}
}
}
if (matched) continue;
// Check if sentence starts with an action verb (medium confidence)
const firstWord = lower.split(/\s+/)[0];
const firstTwo = lower.split(/\s+/).slice(0, 2).join(" ");
if (ACTION_VERBS.includes(firstWord) || ACTION_VERBS.includes(firstTwo)) {
addTask(tasks, seen, capitalise(trimmed), 0.6);
}
}
return tasks;
}
function splitSentences(text) {
// Split on sentence-ending punctuation, newlines, or common speech breaks
return text
.split(/(?<=[.!?])\s+|\n+/)
.flatMap((s) => s.split(/(?:,\s*(?:and\s+)?)?(?=(?:then|also|plus)\s)/i))
.filter((s) => s.trim().length > 0);
}
function extractAfterMarker(sentence, marker) {
const lower = sentence.toLowerCase();
const idx = lower.indexOf(marker);
if (idx < 0) return null;
return sentence.slice(idx + marker.length).trim().replace(/^[:\-–—]\s*/, "");
}
function extractTaskFromPhrase(sentence, phrase, idx) {
// Extract "need to X" → "X"
const afterPhrase = sentence.slice(idx + phrase.length).trim();
if (afterPhrase) return afterPhrase;
return null;
}
function capitalise(text) {
if (!text) return text;
// Clean up leading punctuation/whitespace
const cleaned = text.replace(/^[,;:\-–—\s]+/, "").trim();
if (!cleaned) return text;
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
function addTask(tasks, seen, text, confidence) {
// Remove trailing punctuation for dedup
const key = text.toLowerCase().replace(/[.!?,;:]+$/, "").trim();
if (seen.has(key)) return;
seen.add(key);
tasks.push({ text: text.replace(/[.!?]+$/, "").trim(), confidence });
}

46
src/lib/utils/time.js Normal file
View File

@@ -0,0 +1,46 @@
/** Pad number to 2 digits */
export function pad(n) {
return n.toString().padStart(2, "0");
}
/** Format seconds as M:SS (e.g. 1:05) */
export function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60).toString().padStart(2, "0");
return `${m}:${s}`;
}
/** Format seconds as human duration (e.g. 2m 30s) */
export function formatDuration(seconds) {
if (!seconds) return "";
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
/** Format ISO timestamp as D Mon HH:MM (e.g. 15 Mar 18:11) */
export function formatTimestamp(iso) {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }) +
" " + d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
}
/** Format seconds as SRT timestamp (HH:MM:SS,mmm) */
export function formatTimeSRT(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 1000);
return `${pad(h)}:${pad(m)}:${pad(s)},${ms.toString().padStart(3, "0")}`;
}
/** Format seconds as VTT timestamp (HH:MM:SS.mmm) */
export function formatTimeVTT(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 1000);
return `${pad(h)}:${pad(m)}:${pad(s)}.${ms.toString().padStart(3, "0")}`;
}