import { extractEntities, extractTags, inferKind, scoreHaystack, summarize } from './kinds.ts' import { ensureSchema, sql, toNote, type StashNote } from './db.ts' import { createId, inputRecord, normalizeLimit, normalizeText, nowIso, optionalBoolean, } from './util.ts' export const defaultLimit = 10 export const maxLimit = 50 function scoreNote(note: StashNote, query: string): number { const haystack = [ note.rawText, note.summary, note.kind, note.tags.join(' '), note.entities.join(' '), ].join(' ') const base = scoreHaystack(haystack, query) const normalizedQuery = query.trim().toLowerCase() const tagBoost = note.tags.some( (tag) => normalizedQuery.includes(tag) || tag.includes(normalizedQuery), ) ? 0.2 : 0 return Math.min(1, base + tagBoost) } function dueAtFrom(body: Record, fallback: string | null = null) { if (!('dueAt' in body)) return fallback return typeof body.dueAt === 'string' || body.dueAt === null ? body.dueAt : fallback } function previewNote(input: { id: string text: string kind: string tags: string[] entities: string[] dueAt: string | null archivedAt: string | null createdAt: string updatedAt: string }): StashNote { return { id: input.id, rawText: input.text, summary: summarize(input.text), kind: input.kind, tags: input.tags, entities: input.entities, dueAt: input.dueAt, archivedAt: input.archivedAt, createdAt: input.createdAt, updatedAt: input.updatedAt, } } export async function addNote(input?: unknown) { const body = inputRecord(input ?? {}) const text = normalizeText(body.text) if (!text) throw new Error('addNote requires non-empty text.') const kind = normalizeText(body.kind) || inferKind(text) const tags = extractTags(text, body.tags, kind) const entities = extractEntities(text) const id = createId() const timestamp = nowIso() const dueAt = dueAtFrom(body, null) const note = previewNote({ id, text, kind, tags, entities, dueAt, archivedAt: null, createdAt: timestamp, updatedAt: timestamp, }) if (optionalBoolean(body, 'dryRun')) { return { ok: true as const, dryRun: true as const, note } } await ensureSchema() await sql( 'INSERT INTO notes (id, raw_text, summary, kind, tags_json, entities_json, due_at, archived_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)', [ note.id, note.rawText, note.summary, note.kind, JSON.stringify(note.tags), JSON.stringify(note.entities), note.dueAt, note.createdAt, note.updatedAt, ], ) return { ok: true as const, dryRun: false as const, note } } export async function getNote(input?: unknown): Promise { const body = inputRecord(input ?? {}) const id = normalizeText(body.id) if (!id) throw new Error('getNote requires an id.') await ensureSchema() const result = await sql('SELECT * FROM notes WHERE id = ? LIMIT 1', [id]) const [row] = result.rows ?? [] return row ? toNote(row) : null } export async function updateNote(input?: unknown) { const body = inputRecord(input ?? {}) const id = normalizeText(body.id) if (!id) throw new Error('updateNote requires an id.') const existing = await getNote({ id }) if (!existing) throw new Error(`Note ${id} was not found.`) const text = normalizeText(body.text) || existing.rawText const kind = normalizeText(body.kind) || inferKind(text) const tags = extractTags( text, Array.isArray(body.tags) ? body.tags : existing.tags, kind, ) const entities = extractEntities(text) const dueAt = dueAtFrom(body, existing.dueAt) const updatedAt = nowIso() const note = previewNote({ id, text, kind, tags, entities, dueAt, archivedAt: existing.archivedAt, createdAt: existing.createdAt, updatedAt, }) if (optionalBoolean(body, 'dryRun')) { return { ok: true as const, dryRun: true as const, note } } await sql( 'UPDATE notes SET raw_text = ?, summary = ?, kind = ?, tags_json = ?, entities_json = ?, due_at = ?, updated_at = ? WHERE id = ?', [ note.rawText, note.summary, note.kind, JSON.stringify(note.tags), JSON.stringify(note.entities), note.dueAt, note.updatedAt, id, ], ) return { ok: true as const, dryRun: false as const, note: await getNote({ id }) } } async function selectNotes(includeArchived: boolean): Promise { try { await ensureSchema() const result = await sql( includeArchived ? 'SELECT * FROM notes ORDER BY created_at DESC LIMIT 500' : 'SELECT * FROM notes WHERE archived_at IS NULL ORDER BY created_at DESC LIMIT 500', ) return (result.rows ?? []).map(toNote) } catch (error) { if (error instanceof Error && /no such table/i.test(error.message)) return [] throw error } } export async function searchNotes(input?: unknown) { const body = inputRecord(input ?? {}) const query = normalizeText(body.query) const limit = normalizeLimit(body.limit, defaultLimit, maxLimit) const notes = await selectNotes(body.includeArchived === true) const matches = notes .map((note) => ({ note, score: scoreNote(note, query) })) .filter((entry) => !query || entry.score > 0) .sort( (left, right) => right.score - left.score || right.note.createdAt.localeCompare(left.note.createdAt), ) .slice(0, limit) return { query, matches } } export async function listRecent(input?: unknown) { const body = inputRecord(input ?? {}) const limit = normalizeLimit(body.limit, 20, maxLimit) const notes = await selectNotes(body.includeArchived === true) return { notes: notes.slice(0, limit) } } export async function listNotesByTag(input?: unknown) { const body = inputRecord(input ?? {}) const tag = normalizeText(body.tag).toLowerCase() if (!tag) throw new Error('listNotesByTag requires a tag.') const limit = normalizeLimit(body.limit, 100, 500) const notes = await selectNotes(body.includeArchived === true) const matches = notes .filter((note) => note.tags.includes(tag)) .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) .slice(0, limit) return { tag, count: matches.length, notes: matches } } export async function archiveNote(input?: unknown) { const body = inputRecord(input ?? {}) const id = normalizeText(body.id) if (!id) throw new Error('archiveNote requires an id.') await ensureSchema() const existing = await sql('SELECT * FROM notes WHERE id = ? LIMIT 1', [id]) const [row] = existing.rows ?? [] if (!row) { return { ok: false as const, dryRun: optionalBoolean(body, 'dryRun'), id, error: 'not-found' as const, message: `Note ${id} was not found.`, } } const timestamp = nowIso() if (optionalBoolean(body, 'dryRun')) { return { ok: true as const, dryRun: true as const, id, archivedAt: timestamp, note: toNote(row), } } await sql('UPDATE notes SET archived_at = ?, updated_at = ? WHERE id = ?', [ timestamp, timestamp, id, ]) return { ok: true as const, dryRun: false as const, id, archivedAt: timestamp } } export async function retrieve(input?: unknown) { const body = inputRecord(input ?? {}) const limit = normalizeLimit(body.limit, 5, maxLimit) const result = await searchNotes({ query: normalizeText(body.query), limit, }) return { results: result.matches.map((match) => ({ id: match.note.id, title: match.note.summary, summary: match.note.summary, details: match.note.rawText, score: match.score, source: 'stash', metadata: { kind: match.note.kind, tags: match.note.tags, entities: match.note.entities, dueAt: match.note.dueAt, archivedAt: match.note.archivedAt, }, })), } } export function overview() { return { auth: 'none', storage: 'packageStorage', exports: [ 'add', 'search', 'recent', 'by-tag', 'get', 'update', 'archive', 'retriever', 'app', ], safety: { oauthRequired: false, secretsRequired: false, hostsRequired: [], mutationsSupportDryRun: true, }, forkUrl: 'https://kody.codes/@kody/stash', } }