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<string, unknown>, 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<StashNote | null> {
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<StashNote[]> {
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',
}
}