Arcology Engine

LLM Notes Tools

Contents

Introduction

The arcology2 notes sub-commands provide machine-readable query mechanisms designed to be wrapped by agent harnesses. Harnesses can read, search, query timelines, capture, and query notes without filesystem access.. no constant file_read prompts, no allowlist on ~/org, no leaking private notes to remote inference.

  • No file paths on any read tool. Note access is by org-roam node ID. Queries never return the file path.

  • Private data is omitted from the remote harness. A local GPU runs Together AI's tev1:4b decision model through Ollama's /v1/systemone endpoint (classifier.org) to judge the risk of sending the file content to remote inference providers. nimble:9b and tev1:0.8b speak the same endpoint and swap in via --risk-model.

  • Exclusion matches the web interface. EXCLUDE_TAGS hides nodes, and their subheadings here too. An excluded note returns not_found so its existence isn't confirmed.

  • Fail closed. When the risk classifier gates a note, note-get returns only the echo of the requested ID plus the risk block: no title, tags, aliases, properties, links, or children, because all of those leak signal about otherwise-withheld content. If the classifier is unavailable, the agent harness is locked out from accessing the system.

  • *--verbose is a debug instrument.* Invoked per tool, it attaches the classifier's request/response transcript to the risk block (see classifier.org).

The arcology2 notes Command Group

kotlin#+name: tools-notes-parent
class NotesCommand : CliktCommand(
    name = "notes",
    help = "LLM-harness tools: get, search, timeline, capture, sql"
) {
    override fun run() = Unit
}

Common Base

kotlin#+name: tools-base-command
/**
 * Tags that suppress a note from tool output, mirroring the publishing
 * layer's exclusions. Case-sensitive on purpose: :ARCHIVE: hides,
 * :Archive: (a knowledge-organization tag) does not. Declared once in
 * [[*TimestampsIndexerPlugin — all DTStamp entities at index time]]'s tangle
 * target (commonMain) so both the plugin and the jvmMain tools see it.
 */
const val DEFAULT_MAX_BODY_BYTES: Long = 20_480L

abstract class NoteToolBase(name: String, help: String) : CliktCommand(name = name, help = help) {
    protected val dbPath: String by option("--db", help = "Path to SQLite database")
        .default("~/org/arcology.db")
    protected val orgDir: String by option("--org-dir", help = "Root org-mode directory")
        .default("~/org")
    // Nullable options with no explicit default: Clikt yields null when
    // absent. Env fallback happens in makeGate — same pattern as SyncCommand.
    protected val riskEndpoint: String? by option(
        "--risk-endpoint",
        help = "Ollama http://host:port for the risk classifier (default: \$ARCOLOGY_RISK_ENDPOINT)"
    )
    protected val riskModel: String? by option(
        "--risk-model", help = "Decision model name (default: \$ARCOLOGY_RISK_MODEL or tev1:4b)"
    )
    protected val riskThreshold: Double by option(
        "--risk-threshold", help = "Overall score above which content is redacted (score = rubric level/4; bench calibration: 0.40 ≈ identifying-detail level, clean notes top out ≈ 0.3)"
    ).double().default(0.40)

    /** =--no-risk-gate=: explicit escape hatch for trusted local runs. */
    protected val noRiskGate: Boolean by option(
        "--no-risk-gate", help = "Disable risk gating for this invocation"
    ).flag(default = false)

    /** =--verbose=: attach the classifier input/output transcript to the envelope. */
    protected val verbose: Boolean by option(
        "--verbose", help = "Include the classifier request/response transcript in the risk block"
    ).flag(default = false)

    /**
     * =--ensure-fresh=: run one incremental indexing pass (hash-skip cheap)
     * before the read, for cron contexts that can't run `notes reindex`
     * first. Opt-in; never the default.
     */
    protected val ensureFresh: Boolean by option(
        "--ensure-fresh", help = "Run one incremental index pass before reading"
    ).flag(default = false)

    protected fun expandTilde(path: String) = path.replace("~", System.getProperty("user.home"))

    protected fun openRepo(): ToolQueryRepository =
        ToolQueryRepositoryImpl(DatabaseFactory.createDatabase(expandTilde(dbPath)))

    /** Classifier when endpoint configured; else null — gate fails closed. */
    protected fun makeGate(repo: ToolQueryRepository): RiskGate {
        val endpoint = riskEndpoint ?: System.getenv("ARCOLOGY_RISK_ENDPOINT")
        val model = riskModel ?: System.getenv("ARCOLOGY_RISK_MODEL") ?: "tev1:4b"
        val classifier = endpoint?.let { ep ->
            RiskClassifier(
                endpoint = ep,
                model = model,
                httpClient = HttpClient {
                    install(HttpTimeout) { requestTimeoutMillis = 45_000 }
                }
            )
        }
        return RiskGate(repo, classifier, model = model, trustLocal = noRiskGate)
    }

    suspend fun printEnvelope(envelope: ToolEnvelope) {
        println(ToolJson.encodeToString(ToolEnvelope.serializer(), envelope))
    }

    /** Emit an error envelope and abort with the message. */
    protected suspend fun fail(code: String, message: String): Nothing {
        println(
            ToolJson.encodeToString(
                ToolEnvelope.serializer(),
                ToolEnvelope(ok = false, error = ToolError(code, message))
            )
        )
        throw CliktError(message)
    }
}

arcology2 notes get

Metadata, child discovery stubs, links/refs, and the leaf body — gated.

kotlin#+name: tools-note-get

/** Tool-facing node projection — no file path field, by design. */
data class NodeRecord(
    val id: String,
    val level: Long,
    val todo: String?,
    val priority: String?,
    val scheduled: String?,
    val deadline: String?,
    val title: String?,
    val properties: String?,
    val olp: String?
)

Body rendering — the document-AST walk

The parser's nodeContents map is a content-roll-up aggregate built for FTS indexing: ID-less headings' text rolls up to the nearest ID-bearing ancestor, unfiltered by exclusion tags. notes get instead renders straight from the parsed document AST with publishing-layer exclusion semantics:

  • The focused section's own text: paragraphs, lists, tables, and blocks (quote, verse, src, etc)... HTML export blocks #+begin_html are publisher markup, not prose, and are elided.

  • ID-less nested headings render *inline with their title line as an org heading followed by their text, their text has no node of its own to fetch, and org semantics put it in the parent's content stream. - ID-bearing nested headings collapse to =* Title [id:…]= stubs to be pulled on their own - Any nested heading tagged =noexport=/=NOEXPORT=/=ARCHIVE= is elided entirely*.

  • All this holds for file-level nodes too.

kotlin#+name: tools-body-renderer
/**
 * True when the heading itself carries an exclusion tag (noexport/NOEXPORT/
 * ARCHIVE) — mirrors the publishing layer's EXCLUDE_TAGS semantics. Only the
 * heading's *own* tags count here: inherited exclusion (an excluded parent
 * already removed this subtree during the walk) and excluded ancestors are
 * handled by the recursive render, and filetags deliberately do NOT exclude
 * (a file tagged :noshow: still wants its tool output).
 */
internal fun isExcludedHeading(heading: xyz.lepisma.orgmode.OrgHeading): Boolean {
    val tags = heading.tags?.tags ?: return false
    return tags.any { it in EXCLUDE_TAG_SET }
}

/** Recursive plaintext walk over org chunks with heading rendering. */
internal fun renderChunksPlaintext(
    chunks: List<xyz.lepisma.orgmode.OrgChunk>,
    sb: StringBuilder
) {
    for (chunk in chunks) {
        when (chunk) {
            is xyz.lepisma.orgmode.OrgChunk.OrgParagraph -> {
                val line = chunk.tokens.joinToString("") { it.text }.trim()
                if (line.isNotEmpty()) {
                    sb.appendLine(line)
                    sb.appendLine()
                }
            }
            is xyz.lepisma.orgmode.OrgChunk.OrgTable ->
                (listOfNotNull(chunk.header) + chunk.subtables.flatten())
                    .joinTo(sb, "\n", postfix = "\n\n") { row ->
                        row.cells.joinToString(" | ") { it.plainText() }
                    }
            is xyz.lepisma.orgmode.OrgList.OrgUnorderedList ->
                chunk.items.forEach { item ->
                    item.content.forEach { renderChunksInline(it, sb) }
                }
            is xyz.lepisma.orgmode.OrgList.OrgOrderedList ->
                chunk.items.forEach { item ->
                    item.content.forEach { renderChunksInline(it, sb) }
                }
            is xyz.lepisma.orgmode.OrgSection ->
                renderSectionPlaintext(chunk, sb)
            // Org blocks render: OrgBlock is a sealed *namespace* (its 12
            // subclasses extend OrgChunk directly), so delegate the renderable
            // members explicitly rather than testing the namespace type. HTML
            // and the parser-commented comment/video/center types fall to
            // renderChunksInline's catch-all.
            is xyz.lepisma.orgmode.OrgBlock.OrgQuoteBlock,
            is xyz.lepisma.orgmode.OrgBlock.OrgVerseBlock,
            is xyz.lepisma.orgmode.OrgBlock.OrgSourceBlock,
            is xyz.lepisma.orgmode.OrgBlock.OrgExampleBlock,
            is xyz.lepisma.orgmode.OrgChunk.OrgFixedWidthArea,
            is xyz.lepisma.orgmode.OrgBlock.OrgAsideBlock,
            is xyz.lepisma.orgmode.OrgBlock.OrgEditsBlock,
            is xyz.lepisma.orgmode.OrgBlock.OrgPageIntroBlock ->
                renderChunksInline(chunk, sb)
            else -> { } // keyword lines, logbooks, review data: noise for the harness
        }
    }
}

/** Non-section chunk rendered by [[renderChunksPlaintext]] — shared with lists. */
internal fun renderChunksInline(
    chunk: xyz.lepisma.orgmode.OrgChunk,
    sb: StringBuilder
) {
    when (chunk) {
        is xyz.lepisma.orgmode.OrgChunk.OrgParagraph -> {
            val line = chunk.tokens.joinToString("") { it.text }.trim()
            if (line.isNotEmpty()) {
                sb.appendLine(line)
                sb.appendLine()
            }
        }
        is xyz.lepisma.orgmode.OrgBlock.OrgSourceBlock ->
            if (chunk.body.isNotBlank()) {
                sb.appendLine(chunk.body.trim())
                sb.appendLine()
            }
        is xyz.lepisma.orgmode.OrgBlock.OrgExampleBlock ->
            if (chunk.text.isNotBlank()) {
                sb.appendLine(chunk.text.trim())
                sb.appendLine()
            }
        is xyz.lepisma.orgmode.OrgChunk.OrgFixedWidthArea ->
            if (chunk.text.isNotBlank()) {
                sb.appendLine(chunk.text.trim())
                sb.appendLine()
            }
        is xyz.lepisma.orgmode.OrgChunk.OrgTable ->
            (listOfNotNull(chunk.header) + chunk.subtables.flatten())
                .joinTo(sb, "\n", postfix = "\n\n") { row ->
                    row.cells.joinToString(" | ") { it.plainText() }
                }
        is xyz.lepisma.orgmode.OrgBlock.OrgQuoteBlock ->
            chunk.body.forEach { renderChunksInline(it, sb) }
        is xyz.lepisma.orgmode.OrgList.OrgUnorderedList ->
            chunk.items.forEach { item ->
                item.content.forEach { renderChunksInline(it, sb) }
            }
        is xyz.lepisma.orgmode.OrgList.OrgOrderedList ->
            chunk.items.forEach { item ->
                item.content.forEach { renderChunksInline(it, sb) }
            }
        is xyz.lepisma.orgmode.OrgBlock.OrgVerseBlock ->
            if (chunk.body.isNotBlank()) {
                sb.appendLine(chunk.body.trim())
                sb.appendLine()
            }
        is xyz.lepisma.orgmode.OrgBlock.OrgAsideBlock ->
            chunk.body.forEach { renderChunksInline(it, sb) }
        is xyz.lepisma.orgmode.OrgBlock.OrgEditsBlock ->
            chunk.body.forEach { renderChunksInline(it, sb) }
        is xyz.lepisma.orgmode.OrgBlock.OrgPageIntroBlock ->
            chunk.body.forEach { renderChunksInline(it, sb) }
        is xyz.lepisma.orgmode.OrgBlock.OrgHTMLBlock ->
            { } // publisher markup, not prose; elided from plaintext harness output
        is xyz.lepisma.orgmode.OrgSection ->
            renderSectionPlaintext(chunk, sb)
        else -> { }
    }
}

/**
 * Render one heading's section: excluded headings vanish with their subtree;
 * ID-bearing headings collapse to stubs; ID-less headings render their title
 * line and body inline with recursive exclusion checks.
 */
internal fun renderSectionPlaintext(
    section: xyz.lepisma.orgmode.OrgSection,
    sb: StringBuilder
) {
    val tags = section.heading.tags?.tags ?: emptyList()
    if (tags.any { it in EXCLUDE_TAG_SET }) {
        // Excluded subtree: emit nothing, not even a stub. The publishing
        // layer treats the heading as unpublished; the tool treats it as
        // unknowable.
        return
    }

    val id = section.heading.properties?.map
        ?.get("ID")
        ?.let { line -> line.items.filterIsInstance<xyz.lepisma.orgmode.OrgInlineElem.Text>()
            .joinToString("") { it.text }.trim() }
        ?.takeIf { it.isNotEmpty() }

    if (id != null) {
        // ID-bearing child: stub for discovery. NOT the content — the
        // harness fetches it by ID.
        sb.appendLine("${"*".repeat(section.heading.level.level)} ${section.heading.title.plainText()} [id:$id]")
        sb.appendLine()
        return
    }

    // ID-less heading: title line, then body — its text is part of this
    // body. Render inline.
    sb.appendLine("${"*".repeat(section.heading.level.level)} ${section.heading.title.plainText()}")
    sb.appendLine()
    renderChunksPlaintext(section.body, sb)
}

The loader itself:

kotlin#+name: tools-body-loader
/**
 * Render the plaintext body of a note by ID. For a regular heading node:
 * its own section (ID-less children inline, ID-bearing children as stubs,
 * excluded subtrees elided). For the file-level node: preface + all
 * top-level sections with the same rules.
 *
 * Returns null when the node/file is missing or unparseable — callers
 * treat null as "no body". The node's file path never crosses the tool
 * boundary.
 */
suspend fun loadLeafBody(
    database: ArcologyDatabase,
    nodeId: String,
    orgDir: String,
    maxBytes: Long
): String? = withContext(Dispatchers.IO) {
    val relative = database.toolsQueries.selectToolNodeFileById(nodeId)
        .executeAsOneOrNull() ?: return@withContext null
    val file = File(orgDir, relative)
    if (!file.exists()) return@withContext null
    val parseResult = OrgFileParser().parseFileContent(
        relative, file.readText(), Instant.fromEpochSeconds(0)
    )
    val success = parseResult as? ParseResult.Success ?: return@withContext null
    val document = success.document ?: return@withContext null

    val sb = StringBuilder()
    val isFileLevel = nodeId == document.preamble.properties?.map?.get("ID")
        ?.let { line -> line.items.filterIsInstance<xyz.lepisma.orgmode.OrgInlineElem.Text>()
            .joinToString("") { it.text }.trim() }

    if (isFileLevel == true) {
        // File-level node: preface paragraphs + every top-level section,
        // each under the recursion/exclusion/stub rules.
        renderChunksPlaintext(document.preface.body, sb)
        document.content.forEach { section ->
            renderSectionPlaintext(section, sb)
        }
    } else {
        val section = findToolSection(document.content, nodeId) ?: return@withContext null
        renderChunksPlaintext(section.body, sb)
    }

    val body = sb.toString().trim()
    if (body.isEmpty()) return@withContext null
    if (body.length > maxBytes) {
        body.take(maxBytes.toInt()) + "\n\n[truncated at $maxBytes bytes]"
    } else {
        body
    }
}

/** Section lookup owned by the llm package (same logic as publishing's helper). */
internal tailrec fun findToolSection(
    sections: List<xyz.lepisma.orgmode.OrgSection>,
    nodeId: String
): xyz.lepisma.orgmode.OrgSection? {
    if (sections.isEmpty()) return null
    for (section in sections) {
        val sectionId = section.heading.properties?.map?.get("ID")
            ?.let { line -> line.items.filterIsInstance<xyz.lepisma.orgmode.OrgInlineElem.Text>()
                .joinToString("") { it.text }.trim() }
        if (sectionId == nodeId) return section
    }
    // recurse: sections' nested bodies are the children lists
    val nested = sections.flatMap { it.body.filterIsInstance<xyz.lepisma.orgmode.OrgSection>() }
    return findToolSection(nested, nodeId)
}

The CLI command wrapper:

kotlin#+name: tools-note-get-command
class NoteGetCommand : NoteToolBase(
    name = "get",
    help = "Fetch a note by org ID: metadata, child ids+titles, links, refs, and leaf body as plaintext"
) {
    private val id: String by argument(help = "Org-mode node ID")
    private val maxBodyBytes: Long by option("--max-body-bytes", help = "Body size cap in bytes")
        .long().default(DEFAULT_MAX_BODY_BYTES)

    override fun run() = runBlocking {
        if (ensureFresh) runFreshIndexPass(expandTilde(dbPath), expandTilde(orgDir))
        val database = computer.whatthefuck.arcology.database.DatabaseFactory.createDatabase(expandTilde(dbPath))
        val repo = ToolQueryRepositoryImpl(database)

        val record = repo.selectNodeById(id)
            ?: return@runBlocking fail("not_found", "no such note")
        val ancestorTags = repo.selectAncestorTags(id)
        if (ancestorTags.any { it in EXCLUDE_TAG_SET }) {
            fail("not_found", "no such note")
        }

        val body = loadLeafBody(database, id, expandTilde(orgDir), maxBodyBytes)

        val gate = makeGate(repo)
        val gateResult = if (body != null) gate.gate(body, riskThreshold, verbose) else null
        val gated = gateResult?.gated == true

        // Gated metadata policy: a redacted note returns only what the
        // harness itself supplied — the ID (echo of the request) and the
        // risk block. Links/tags/aliases/properties/children all leak
        // signal about otherwise-withheld content, so none of them flow.
        val data = if (gated) {
            buildJsonObject {
                put("id", JsonPrimitive(record.id))
                put("body", buildJsonObject { put("redacted", JsonPrimitive(true)) })
            }
        } else {
            val linksFrom = repo.selectLinksFrom(id)
            val linksTo = repo.selectLinksTo(id)
            val rawLinks = repo.selectRawLinks(id)
            val refs = repo.selectRefsByNode(id)
            val aliases = repo.selectAliasesByNode(id)
            val tags = repo.selectTagsByNode(id)
            val children = repo.selectChildNodes(id)
            buildJsonObject {
                put("id", JsonPrimitive(record.id))
                record.title?.let { put("title", JsonPrimitive(it)) }
                record.todo?.let { put("todo", JsonPrimitive(it)) }
                record.priority?.let { put("priority", JsonPrimitive(it)) }
                record.scheduled?.let { put("scheduled", JsonPrimitive(it)) }
                record.deadline?.let { put("deadline", JsonPrimitive(it)) }
                put("tags", JsonArray(tags.map { JsonPrimitive(it) }))
                put("aliases", JsonArray(aliases.map { JsonPrimitive(it) }))
                put("properties", parsePropertiesString(record.properties))
                put("children", JsonArray(children.map { c ->
                    buildJsonObject {
                        put("id", JsonPrimitive(c.id))
                        c.title?.let { put("title", JsonPrimitive(it)) }
                        c.todo?.let { put("todo", JsonPrimitive(it)) }
                    }
                }))
                put("links", buildJsonObject {
                    put("outgoing", JsonArray(linksFrom.map { l ->
                        buildJsonObject {
                            put("id", JsonPrimitive(l.targetId))
                            l.targetTitle?.let { put("title", JsonPrimitive(it)) }
                        }
                    }))
                    put("incoming", JsonArray(linksTo.map { l ->
                        buildJsonObject {
                            put("id", JsonPrimitive(l.targetId))
                            l.targetTitle?.let { put("title", JsonPrimitive(it)) }
                        }
                    }))
                    put("external", JsonArray(rawLinks.map { rl ->
                        buildJsonObject {
                            rl.type?.let { put("type", JsonPrimitive(it)) }
                            rl.properties?.takeIf { it.isNotBlank() }?.let { put("target", JsonPrimitive(it)) }
                        }
                    }))
                })
                put("refs", JsonArray(refs.map { r ->
                    buildJsonObject {
                        put("ref", JsonPrimitive(r.ref))
                        put("type", JsonPrimitive(r.type))
                    }
                }))
                put(
                    "body",
                    when {
                        body == null -> buildJsonObject {
                            put("redacted", JsonPrimitive(true))
                            put("missing", JsonPrimitive(true))
                        }
                        else -> JsonPrimitive(body)
                    }
                )
            }
        }

        printEnvelope(ToolEnvelope(ok = true, data = data, risk = gateResult?.block))
    }
}

/** Parse the serialized `properties` column ("k: v\nk2: v2…") into a JSON object. */
internal fun parsePropertiesString(raw: String?): kotlinx.serialization.json.JsonObject {
    if (raw.isNullOrBlank()) return kotlinx.serialization.json.buildJsonObject { }
    val obj = kotlinx.serialization.json.buildJsonObject {
        raw.lineSequence()
            .map { it.trim() }
            .filter { it.contains(": ") }
            .forEach { line ->
                val idx = line.indexOf(": ")
                put(line.substring(0, idx), JsonPrimitive(line.substring(idx + 2)))
            }
    }
    return obj
}

This subcommand uses the existing SearchService (BM25 over FTS). It outputs IDs/titles/outline only, content is revealed via notes get where classification gates access. Excluded-tagged nodes are filtered even as titles.

arcology2 notes timeline

An agent needs a way to see recent entries, to understand the current focus and flow. Timeline v2 reads tool_timestamps — every timestamp entity at index time: planning =SCHEDULED=/=DEADLINE=/=CLOSED=, LOGBOOK state changes, and inline active and inactive stamps on headings with or without :ID: — plus the classic journal-node sweep over daily files. Per rrix:

  • ID-less rows are first-class: id is null, context comes from olp + snippet. There is no notes get on such rows by construction — no new read surface opens.

  • Per-row gating: each row's snippet (+title) is classified individually; only tripped rows collapse to {"kind":…,"date":…,"redacted":true} — snippet, id, olp, title withheld — while clean rows stay fully visible. The verdict cache keeps repeat windows cheap. Trades whole-output redaction for per-row context; the count of visible rows still flows (only a fully-tripped window redacts nothing back — there IS no all-redacted shape here).

  • --kind is a CSV over scheduled,deadline,closed,active,inactive,logbook,journal; *default = all of them, inactive included. journal rows (ID'd journal headings from daily files) merge into the same list by date. - Old =scheduled=/=journal= two-array shape is replaced*; the one consumer (the VM gateway's journal-sweep cron) migrates at landing.

kotlin#+name: tools-note-timeline
internal val DATE_RE = Regex("""\d{4}-\d{2}-\d{2}""")

private val TIMELINE_KINDS = setOf(
    "scheduled", "deadline", "closed", "active", "inactive", "logbook", "journal"
)

class NoteTimelineCommand : NoteToolBase(
    name = "timeline",
    help = "Timestamp entities in a date range (planning, logbook, inline stamps, journal entries)"
) {
    private val from: String by option("--from", help = "Start date YYYY-MM-DD (inclusive)").required()
    private val to: String by option("--to", help = "End date YYYY-MM-DD (inclusive)").required()
    private val kind: String by option(
        "--kind",
        help = "CSV of scheduled,deadline,closed,active,inactive,logbook,journal (default: all)"
    ).default(TIMELINE_KINDS.joinToString(","))

    override fun run() = runBlocking {
        if (!DATE_RE.matches(from) || !DATE_RE.matches(to)) {
            fail("bad_date", "--from/--to must be YYYY-MM-DD")
        }
        val kinds = kind.split(",").map { it.trim().lowercase() }.filter { it.isNotEmpty() }
        val unknown = kinds.filter { it !in TIMELINE_KINDS }
        if (unknown.isNotEmpty()) {
            fail("bad_kind", "unknown kinds '$unknown'; valid: $TIMELINE_KINDS")
        }
        if (ensureFresh) runFreshIndexPass(expandTilde(dbPath), expandTilde(orgDir))

        val repo = openRepo()

        // Stamp rows: tool_timestamps over the window (journal entries
        // themselves are ID'd headings in the daily files, joined by the
        // classic sweep when requested).
        val stamps = if (kinds.any { it != "journal" }) {
            repo.selectTimestampsBetween(from, to).filter { it.kind in kinds }
        } else emptyList()
        val journal = if ("journal" in kinds) repo.selectJournalNodesBetween(
            "journals/$from.org", "journals/$to.org"
        ) else emptyList()

        val gate = makeGate(repo)

        data class Row(val date: String, val epoch: Long?, val json: JsonObject)

        fun redact(row: JsonObject): JsonObject = buildJsonObject {
            row["kind"]?.let { put("kind", it) }
            row["date"]?.let { put("date", it) }
            put("redacted", JsonPrimitive(true))
        }

        // Per-row gating: each row's snippet/title/olp classified alone;
        // tripped rows keep only kind/date and a redacted flag.
        suspend fun stampRow(s: TimelineStampRecord): Row {
            val json = buildJsonObject {
                s.nodeId?.let { put("id", JsonPrimitive(it)) }
                s.nodeTitle?.let { put("title", JsonPrimitive(it)) }
                s.nodeTodo?.let { put("todo", JsonPrimitive(it)) }
                put("kind", JsonPrimitive(s.kind))
                put("date", JsonPrimitive(s.date))
                s.epoch?.let { put("epoch", JsonPrimitive(it)) }
                s.repeater?.let { put("repeater", JsonPrimitive(it)) }
                s.olp?.takeIf { it.isNotBlank() }?.let { put("olp", JsonPrimitive(it)) }
                s.snippet?.let { put("snippet", JsonPrimitive(it)) }
            }
            val gateable = listOfNotNull(s.nodeTitle, s.nodeTodo, s.snippet, s.olp).joinToString("\n")
            return if (gateable.isBlank()) {
                Row(s.date, s.epoch, json)
            } else {
                val result = gate.gate(gateable, riskThreshold, verbose)
                if (result.gated) Row(s.date, s.epoch, redact(json)) else Row(s.date, s.epoch, json)
            }
        }

        suspend fun journalRow(j: TimelineRecord): Row {
            val day = j.id.take(10) // datetime ID prefix → day
            val json = buildJsonObject {
                put("id", JsonPrimitive(j.id))
                j.title?.let { put("title", JsonPrimitive(it)) }
                j.todo?.let { put("todo", JsonPrimitive(it)) }
                put("kind", JsonPrimitive("journal"))
                put("date", JsonPrimitive(day))
            }
            val gateable = j.title ?: return Row(day, null, json)
            val result = gate.gate(gateable, riskThreshold, verbose)
            return if (result.gated) Row(day, null, redact(json)) else Row(day, null, json)
        }

        val all = (stamps.map { stampRow(it) } + journal.map { journalRow(it) }).sortedWith(
            compareBy({ it.date }, { it.epoch ?: Long.MAX_VALUE })
        )

        val data = buildJsonObject {
            put("entries", JsonArray(all.map { it.json }))
            put("entry_count", JsonPrimitive(all.size))
        }
        printEnvelope(ToolEnvelope(ok = true, data = data))
    }
}

NEXT this should pull [in]active timestamps and whatnot directly from the Tasks table in the Agenda sub-system.

arcology2 notes todo

Toggle a node's TODO state by positional ID, writing through to the org file via the OrgDocumentEditor. This is the same text-surgery path the Android editor uses, including :LOGBOOK: state-change entries and repeater advance-on-DONE (persistAndReindex refreshes the DB).

  • Keyword whitelist comes from the AppPreferences surface: --todo-states CSV or $ARCOLOGY_TODO_STATES, defaulting to NEXT,INPROGRESS,DONE (AppPreferences' DEFAULT_TODO_STATES). --clear removes the keyword.

  • Excluded-tag ancestors ⇒ not_found, same as reads.

  • Writes don't exfiltrate: no classifier call on the write path. The envelope echoes =from=/=to=/=rescheduled= but never the file path.

kotlin#+name: tools-note-todo
class NoteTodoCommand : NoteToolBase(
    name = "todo",
    help = "Toggle a note's TODO state by ID; writes LOGBOOK and honors repeaters"
) {
    private val id: String by argument(help = "Org-mode node ID")
    private val set: String? by option("--set", help = "TODO keyword to set (validated against --todo-states)")
    private val clear: Boolean by option("--clear", help = "Remove the TODO keyword").flag(default = false)
    private val todoStates: String by option(
        "--todo-states",
        help = "CSV of allowed keywords (default: \$ARCOLOGY_TODO_STATES or NEXT,INPROGRESS,DONE)"
    ).default("")

    override fun run() = runBlocking {
        if (set != null && clear) {
            fail("bad_args", "--set and --clear are mutually exclusive")
        }
        if (set == null && !clear) {
            fail("bad_args", "nothing to do: pass --set <STATE> or --clear")
        }
        val statesCsv = todoStates.ifBlank {
            System.getenv("ARCOLOGY_TODO_STATES") ?: "NEXT,INPROGRESS,DONE"
        }
        val states = statesCsv.split(",").map { it.trim() }.filter { it.isNotEmpty() }
        if (set != null && set !in states) {
            fail("bad_todo", "'$set' not in todo states [$statesCsv]; add it to --todo-states or ARCOLOGY_TODO_STATES")
        }

        val database = DatabaseFactory.createDatabase(expandTilde(dbPath))
        val repo = ToolQueryRepositoryImpl(database)
        repo.selectNodeById(id) ?: return@runBlocking fail("not_found", "no such note")
        val ancestorTags = repo.selectAncestorTags(id)
        if (ancestorTags.any { it in EXCLUDE_TAG_SET }) {
            fail("not_found", "no such note")
        }

        val fileSystem = JvmFileSystem(java.nio.file.Paths.get(expandTilde(orgDir)))
        val roamRepo = RoamRepositoryImpl(database)
        val indexService = createIndexingService(
            databasePath = expandTilde(dbPath), config = IndexingConfig(), basePath = expandTilde(orgDir)
        )
        val editor = computer.whatthefuck.arcology.editor.OrgDocumentEditor(
            fileSystem = fileSystem,
            repository = roamRepo,
            indexingService = indexService
        )
        val from = repo.selectNodeById(id)?.todo
        val result = editor.updateTodoState(id, if (clear) null else set)
        when (result) {
            is computer.whatthefuck.arcology.editor.EditResult.Success -> printEnvelope(
                ToolEnvelope(
                    ok = true,
                    data = buildJsonObject {
                        put("id", JsonPrimitive(id))
                        put("from", JsonPrimitive(from))
                        put("to", JsonPrimitive(if (clear) null else set))
                        // Repeater advance-on-DONE rewrites the planning
                        // line; the editor doesn't report it, so surface
                        // success only.
                    },
                    risk = null
                )
            )
            is computer.whatthefuck.arcology.editor.EditResult.Error ->
                fail("todo_failed", result.message)
        }
    }
}

arcology2 notes capture

Direct write via the same CaptureService path the Android app uses: build the heading, append to the daily file, re-index it so the new node is immediately queryable. Captured entries are plain headings unless --gen-id opts into generating the datetime ID. Passing --heading <id> files the entry as the target node's last child instead of the journal, via OrgDocumentEditor.appendHeadingUnder.

The repeatable --ref <URL> option attaches ROAM_REFS to the new node: every occurrence appends one quoted token to the :ROAM_REFS: property, in the space-separated quote-aware multi-value syntax the Android capture core emits and the parser reads back. Because the parser lifts heading properties (refs included) only from ID-bearing headings, --ref implies the datetime ID — an ID-less entry would write the drawer to the file and then drop its refs at index time. The refs then flow through the standard indexing pipeline into the refs table keyed by the envelope's node_id: notes get returns them in the refs array, and published pages' external-reference sections (the web-ref backlinks of the source URL) read the same rows, so no hand property editing is needed. Values are trimmed, blank values are rejected, repeats deduplicate, and the list rides the risk gate alongside --title / --body. Verify after a capture:

SELECT ref, type FROM refs WHERE node_id = '<node_id>';
kotlin#+name: tools-note-capture
class NoteCaptureCommand : NoteToolBase(
    name = "capture",
    help = "Append a capture entry to the daily journal or under a heading (--heading), and re-index"
) {
    private val title: String by option("--title", help = "Entry title").required()
    private val bodyOpt: String by option("--body", help = "Entry body text").default("")
    private val tags: String by option("--tags", help = "Comma-separated tags").default("")
    private val todo: String? by option("--todo", help = "TODO keyword, e.g. TODO")
    private val date: String by option("--date", help = "'today' or YYYY-MM-DD (journal targets only)").default("today")
    private val heading: String? by option("--heading", help = "Target heading node ID: file the entry as its last child instead of the journal")
    private val genId: Boolean by option("--gen-id", help = "Generate and attach a datetime :ID: to the new entry (default: no ID)").flag(default = false)
    private val refs: List<String> by option(
        "--ref", metavar = "URL",
        help = "ROAM_REFS entry for the new node (repeatable). Implies --gen-id"
    ).multiple()

    override fun run() = runBlocking {
        val targetDate = if (heading == null) {
            if (date != "today" && !DATE_RE.matches(date)) {
                fail("bad_date", "--date must be 'today' or YYYY-MM-DD")
            }
            if (date == "today") {
                val now = java.time.LocalDate.now()
                "%04d-%02d-%02d".format(now.year, now.monthValue, now.dayOfMonth)
            } else {
                date
            }
        } else null

        val repo = openRepo()
        val fileSystem = JvmFileSystem(java.nio.file.Paths.get(expandTilde(orgDir)))
        val now = (kotlin.time.Clock.System.now().toLocalDateTime(kotlinx.datetime.TimeZone.currentSystemDefault()))

        val cleanedRefs = refs.map { it.trim() }.filter { it.isNotEmpty() }.distinct()
        if (refs.isNotEmpty() && cleanedRefs.isEmpty()) {
            fail("bad_ref", "--ref values must not be blank")
        }

        val captureId: String? = if (genId || cleanedRefs.isNotEmpty()) {
            // --ref implies an ID: the parser lifts heading properties
            // (ROAM_REFS included) only for ID-bearing nodes — an ID-less
            // entry would drop its refs on the floor at index time.
            CaptureService.generateId(
                LocalDateTime(
                    now.date.year, now.date.monthNumber, now.date.dayOfMonth,
                    now.time.hour, now.time.minute, now.time.second, 0
                )
            )
        } else null

        val entry = CaptureService.buildCaptureEntry(
            body = bodyOpt,
            title = title,
            tags = tags.split(",").map { it.trim() }.filter { it.isNotEmpty() },
            todoState = todo,
            refs = cleanedRefs,
            id = captureId,
            time = LocalTime(now.hour, now.minute)
        )

        // Classify before writing so the score lands in the envelope.
        val gate = makeGate(repo)
        val gateResult = gate.gate(
            (listOf(title, bodyOpt) + cleanedRefs).joinToString("\n"), riskThreshold, verbose
        )

        val headingTarget: String? = heading // capture: delegated props don't smart-cast

        val config = IndexingConfig(
            batchSize = 1, memoryMonitoring = false, enableFtsDefer = false,
            exportDatabaseAfterIndexing = false
        )
        val indexer = createIndexingService(
            databasePath = expandTilde(dbPath), config = config, basePath = expandTilde(orgDir)
        )

        if (headingTarget != null) {
            val targetId: String = headingTarget
            repo.selectNodeById(targetId)
                ?: return@runBlocking fail("not_found", "no such note")
            val ancestorTags = repo.selectAncestorTags(targetId)
            if (ancestorTags.any { it in EXCLUDE_TAG_SET }) {
                fail("not_found", "no such note")
            }

            val editor = computer.whatthefuck.arcology.editor.OrgDocumentEditor(
                fileSystem = fileSystem,
                repository = computer.whatthefuck.arcology.database.RoamRepositoryImpl(
                    DatabaseFactory.createDatabase(expandTilde(dbPath))
                ),
                indexingService = indexer
            )
            when (val result = editor.appendHeadingUnder(targetId, entry)) {
                is computer.whatthefuck.arcology.editor.EditResult.Success -> printEnvelope(
                    ToolEnvelope(
                        ok = true,
                        data = buildJsonObject {
                            put("target_id", JsonPrimitive(targetId))
                            put("filed", JsonPrimitive("under"))
                            captureId?.let { put("node_id", JsonPrimitive(it)) }
                        },
                        risk = gateResult.block
                    )
                )
                is computer.whatthefuck.arcology.editor.EditResult.Error ->
                    fail("capture_failed", result.message)
            }
        } else {
            val relativePath = "journals/$targetDate.org"
            // First capture of the day: the daily file may not exist yet.
            // Create it with the built-in level-0 header so CLI-only days
            // get a proper file-level node (the JVM CLI cannot read Android
            // preferences; custom daily-file-header content stays
            // Android-side only — the identity drawer and header structure
            // stay standardized from both capture entry points).
            ensureDailyFileHeader(fileSystem, relativePath, now)
            fileSystem.appendToFile(relativePath, "\n" + entry)

            when (val result = indexer.indexFile(relativePath)) {
                is FileIndexResult.Success -> printEnvelope(
                    ToolEnvelope(
                        ok = true,
                        data = buildJsonObject {
                            captureId?.let { put("node_id", JsonPrimitive(it)) }
                            put("file", JsonPrimitive(relativePath))
                            put("nodes_indexed", JsonPrimitive(result.nodesCount))
                        },
                        risk = gateResult.block
                    )
                )
                is FileIndexResult.Error -> fail("index_failed", result.message)
                is FileIndexResult.Skipped -> fail("index_skipped", result.reason)
            }
        }
    }
}

Daily file header creation (the level-0 node)

When notes capture targets a journal file that does not exist yet, the first write must create the file-level (level-0) node: properties drawer with a generated :ID: and the file title. The built-in DEFAULT_DAILY_FILE_PATTERN is used unconditionally — the JVM CLI cannot read Android preferences, so custom daily-file-header content is an Android-side-only feature; the identity drawer and header structure stay standardized from both capture entry points (the Android capture core applies the same guarantee via its :ID:-guard). Existing files are never touched, so Emacs-written days are left alone.

kotlin#+name: tools-capture-daily-header
/**
 * Ensure a daily journal file exists. When the file is absent, write the
 * built-in daily-file header (CaptureService.DEFAULT_DAILY_FILE_PATTERN,
 * ID-guarded) so CLI-only days get a proper level-0 node; the caller then
 * appends the capture entry. Existing files are never touched.
 */
suspend fun ensureDailyFileHeader(
    fileSystem: FileSystemInterface,
    relativePath: String,
    dateTime: LocalDateTime
) {
    if (fileSystem.fileExists(relativePath)) return
    val micros = (Clock.System.now().nanosecondsOfSecond / 1000)
    val header = CaptureService.buildDailyFileContent(
        dateTime, CaptureService.generateId(dateTime, micros)
    )
    fileSystem.appendToFile(relativePath, header)
}

arcology2 notes sql

This provides a read-only query system for the SQLite DB. The results are gated by the classifier because it's possible to query the full-text database using this tool.

kotlin#+name: tools-note-sql
class NoteSqlCommand : NoteToolBase(
    name = "sql",
    help = "Run a read-only SELECT against the arcology database"
) {
    private val query: String by option("--query", "-q", help = "A single SELECT statement").required()
    private val maxRows: Int by option("--max-rows", help = "Row cap").int().default(200)

    override fun run() = runBlocking {
        val trimmed = query.trim()
        val statement = trimmed.removeSuffix(";").trim()
        val firstWord = statement.split(Regex("\\s+"), limit = 2).firstOrNull()?.uppercase()
        if (statement.isEmpty() || firstWord != "SELECT" || statement.contains(';')) {
            fail("rejected", "only a single-statement SELECT is allowed")
        }

        val repo = openRepo()
        val rows = try {
            executeReadOnlySelect(statement, maxRows, expandTilde(dbPath))
        } catch (e: Exception) {
            fail("sql_error", e.message ?: "query failed")
        }

        val gate = makeGate(repo)
        val gateResult = gate.gate(
            rows.joinToString("\n") { row -> row.entries.joinToString(" | ") { "${it.key}: ${it.value ?: ""}" } },
            riskThreshold,
            verbose
        )

        val data = buildJsonObject {
            if (gateResult.gated) {
                // Gated: only the risk block carries information. Row count
                // itself leaks (a count of sensitive rows is sensitive).
                put(
                    "rows",
                    buildJsonObject { put("redacted", JsonPrimitive(true)) }
                )
            } else {
                put(
                    "rows",
                    JsonArray(rows.map { row ->
                                  buildJsonObject { row.forEach { (k, v) -> put(k, JsonPrimitive(v)) } } })
                )
                put("row_count", JsonPrimitive(rows.size))
            }
        }
        printEnvelope(ToolEnvelope(ok = true, data = data, risk = gateResult.block))
    }
}

The SQL Runtime

kotlin#+name: tools-sql-runtime
/**
 * Indexer freshness, shared by the `reindex` verb and the read tools'
 * =--ensure-fresh=. A thin adapter over the same
 * =createIndexingService(...).indexDirectory()= pipeline =arcology index=
 * drives — identical plugins and hash-skip — differing only in defaults
 * (notes-style --db/--org-dir) and output: a parseable {ok,data} envelope
 * instead of ad-hoc terminal rendering. Delta vs running =arcology index=
 * directly: no db-into-indexed-dir default, no exportDatabaseAfterIndexing
 * copy, no interactive progress bar — cron/skill consumable output.
 */
suspend fun runFreshIndexPass(dbPath: String, orgDir: String): IndexResult {
    val config = IndexingConfig(
        batchSize = 50, memoryMonitoring = false, enableFtsDefer = false,
        exportDatabaseAfterIndexing = false
    )
    val indexer = createIndexingService(
        databasePath = dbPath, config = config, basePath = orgDir
    )
    return indexer.indexDirectory(orgDir)
}

/**
 * Open the database read-only and run [sql], returning up to [maxRows] rows
 * with cell values truncated to [maxCellChars].
 *
 * Read-only enforcement is two-layered: PRAGMA query_only=ON makes the
 * connection itself reject any write (INSERT/UPDATE/DDL/PRAGMA writes), and
 * the CLI's statement-shape guard runs before this. The xerial driver is
 * already on the runtime classpath via sqldelight's sqlite-driver, so the
 * org.sqlite.SQLiteConfig convenience is unnecessary — connection properties
 * suffice.
 */
fun executeReadOnlySelect(
    sql: String,
    maxRows: Int,
    dbPath: String,
    maxCellChars: Int = 2000
): List<Map<String, String?>> {
    val path = dbPath.replace("~", System.getProperty("user.home"))
    val props = Properties().apply {
        put("open_mode", "1")  // SQLITE_OPEN_READONLY
        put("busy_timeout", "5000")
        put("query_only", "true")
    }
    DriverManager.getConnection("jdbc:sqlite:$path", props).use { connection ->
        connection.createStatement().use { statement ->
            statement.queryTimeout = 10
            statement.executeQuery(sql).use { resultSet ->
                val meta = resultSet.metaData
                val columns = (1..meta.columnCount).map { i -> i to meta.getColumnName(i) }
                val rows = mutableListOf<Map<String, String?>>()
                while (resultSet.next() && rows.size < maxRows) {
                    rows.add(columns.associate { (i, name) ->
                        name to resultSet.getString(i)?.take(maxCellChars)
                    })
                }
                return rows
            }
        }
    }
}

arcology2 notes reindex

Indexer freshness for agent consumers: the skill/cron runs this before working, or relies on the sync watcher. It is a thin adapter — one shared runFreshIndexPass, notes-consistent defaults and envelope output, no second engine.

kotlin#+name: tools-note-reindex
class NoteReindexCommand : NoteToolBase(
    name = "reindex",
    help = "Run one incremental indexing pass (hash-skip: cheap) so notes reads are fresh"
) {
    override fun run() = runBlocking {
        val result = try {
            runFreshIndexPass(expandTilde(dbPath), expandTilde(orgDir))
        } catch (e: Exception) {
            fail("index_failed", e.message ?: "indexing failed")
        }
        printEnvelope(
            ToolEnvelope(
                ok = result.failed == 0,
                data = buildJsonObject {
                    put("indexed", JsonPrimitive(result.successful))
                    put("skipped", JsonPrimitive(result.skipped))
                    put("failed", JsonPrimitive(result.failed))
                    put("removed", JsonPrimitive(result.removed))
                },
                risk = null
            )
        )
    }
}

Tool Plumbing

Tools' SQLDelight Queries

Tools.sq is a new file in the shared ArcologyDatabase, it has the queries the tools below use, and provides a cache for the risk classifier. SQLDelight names its query holder toolsQueries from the filename.

sql#+name: tools-schema:tangle ../src/commonMain/sqldelight/computer/whatthefuck/arcology/db/Tools.sq
-- Verdict cache for the risk classifier (llm/classifier.org), keyed by
-- content hash + model — re-classification happens when content or model
-- changes, not on a wall clock.
CREATE TABLE IF NOT EXISTS llm_verdict_cache (
  content_hash TEXT NOT NULL,
  model TEXT NOT NULL,
  health REAL NOT NULL,
  private_score REAL NOT NULL,
  financial REAL NOT NULL,
  employer REAL NOT NULL,
  political REAL NOT NULL,
  created_at INTEGER NOT NULL,
  PRIMARY KEY (content_hash, model)
);

insertVerdict:
INSERT OR REPLACE INTO llm_verdict_cache
  (content_hash, model, health, private_score, financial, employer, political, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);

selectVerdict:
SELECT health, private_score, financial, employer, political, created_at
FROM llm_verdict_cache WHERE content_hash = ? AND model = ?;

-- Timestamp entities harvested at index time by TimestampsIndexerPlugin:
-- planning SCHEDULED/DEADLINE/CLOSED, LOGBOOK drawer entries, and inline
-- DTStamp/DTRange tokens in heading titles and body chunks — active AND
-- inactive, for headings with or without an :ID: (timeline requirement in
-- rrix's note 20261001T212917.593002). Not FK-locked to task_headings —
-- that table (agenda/models.org) only carries task-ish headings and its
-- extractor drops inactive stamps by design.
-- pos: heading char offset (heading.tokens.first().range.first), 0 for
-- file-level stamps. offset: the stamp/logbook line's char offset —
-- disambiguates same (heading, kind, day) rows and aids debugging.
-- kind: scheduled | deadline | closed | active | inactive | logbook.
-- Rows from EXCLUDE_TAG subtrees (noexport/NOEXPORT/ARCHIVE) are never
-- inserted at all, so the tool query needs no read-time tag check.
CREATE TABLE IF NOT EXISTS tool_timestamps (
  file TEXT NOT NULL,
  pos INTEGER NOT NULL,
  kind TEXT NOT NULL,
  date TEXT NOT NULL,
  epoch INTEGER,
  repeater TEXT,
  node_id TEXT,
  olp TEXT,
  snippet TEXT,
  offset INTEGER NOT NULL,
  PRIMARY KEY (file, pos, kind, offset)
);

CREATE INDEX IF NOT EXISTS idx_tool_timestamps_date ON tool_timestamps (date);
CREATE INDEX IF NOT EXISTS idx_tool_timestamps_node ON tool_timestamps (node_id);

insertToolTimestamp:
INSERT OR REPLACE INTO tool_timestamps
  (file, pos, kind, date, epoch, repeater, node_id, olp, snippet, offset)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);

deleteToolTimestampsByFile:
DELETE FROM tool_timestamps WHERE file = ?;

Tool-facing node projection. Omits `file` and `pos`: the harness never learns file paths from these tools.

sql:tangle ../src/commonMain/sqldelight/computer/whatthefuck/arcology/db/Tools.sq
selectToolNodeById:
SELECT id, level, todo, priority, scheduled, deadline, title, properties, olp
FROM nodes WHERE id = ?;

The file path, for local body loading ONLY. Never serialized to tool output; note-get fetches this internally to parse the section body.

sql:tangle ../src/commonMain/sqldelight/computer/whatthefuck/arcology/db/Tools.sq
selectToolNodeFileById:
SELECT file AS file FROM nodes WHERE id = ?;

All tags on the node's ancestor chain (including itself): EXCLUDE_TAGS enforcement and inherited-tag visibility in one query. Wrapped with an ORDER BY alias so the single selected column still names the row type.

sql:tangle ../src/commonMain/sqldelight/computer/whatthefuck/arcology/db/Tools.sq
selectToolAncestorTags:
SELECT DISTINCT t.tag AS tag
FROM node_ancestors na
JOIN tags t ON t.node_id = na.ancestor_id
WHERE na.node_id = ?;

Position bracket [myPos, nextSiblingPos) of a node in its file — the range potentially containing its descendants.

sql:tangle ../src/commonMain/sqldelight/computer/whatthefuck/arcology/db/Tools.sq
selectToolNodeBracket:
SELECT me.pos AS my_pos,
       (SELECT MIN(n2.pos) FROM nodes n2 WHERE n2.file = me.file AND n2.level <= me.level AND n2.pos > me.pos) AS bracket_end
FROM nodes me WHERE me.id = ?;

Direct children: same file, one level deeper, inside the bracket. The bracket end (next sibling-or-ancestor position) is computed inline so the generated query takes only the node id.

sql:tangle ../src/commonMain/sqldelight/computer/whatthefuck/arcology/db/Tools.sq
selectToolChildNodes:
SELECT n.id, n.title, n.todo, n.scheduled, n.deadline
FROM nodes n
JOIN nodes me ON me.id = ?1
WHERE n.file = me.file
  AND n.level = me.level + 1
  AND n.pos > me.pos
  AND n.pos < COALESCE(
      (SELECT MIN(n3.pos) FROM nodes n3
       WHERE n3.file = me.file AND n3.level <= me.level AND n3.pos > me.pos),
      2147483647)
ORDER BY n.pos;

All descendants (any depth) inside the bracket: the elision set for plaintext rendering and discovery stubs.

sql:tangle ../src/commonMain/sqldelight/computer/whatthefuck/arcology/db/Tools.sq
selectToolDescendantIds:
SELECT n.id
FROM nodes n
JOIN nodes me ON me.id = ?
WHERE n.file = me.file
AND n.level > me.level
AND n.pos > me.pos
AND n.pos < COALESCE(
(SELECT MIN(n3.pos) FROM nodes n3
WHERE n3.file = me.file AND n3.level <= me.level AND n3.pos > me.pos),
2147483647)
ORDER BY n.pos;

The "direct children / descendants" problem: the closure table can't express direct parent, so children derive from olp + level + pos. A child of node me is in the same file, one level deeper, positioned after me, and its olp is me's olp + one title. Since titles may contain /:, we resolve children by position bracketing: the child range of me spans from me.pos up to the position of the next node at level <= me.level (exclusive) in the same file. Every node inside that bracket at exactly me.level + 1 is a direct child.

Links resolved to node ids+titles.

sql:tangle ../src/commonMain/sqldelight/computer/whatthefuck/arcology/db/Tools.sq
selectToolLinksFrom:
SELECT l.to_node AS target_id, n.title AS target_title, l.type
FROM links l
LEFT JOIN nodes n ON n.id = l.to_node
WHERE l.from_node = ? AND l.to_node IS NOT NULL
ORDER BY l.pos;

selectToolLinksTo:
SELECT l.from_node AS target_id, n.title AS target_title, l.type
FROM links l
LEFT JOIN nodes n ON n.id = l.from_node
WHERE l.to_node = ?
ORDER BY l.pos;

Unresolved links (external/file/…) so web refs stay visible.

sql:tangle ../src/commonMain/sqldelight/computer/whatthefuck/arcology/db/Tools.sq
selectToolRawLinks:
SELECT type, properties FROM links
WHERE from_node = ? AND to_node IS NULL;

selectToolRefsByNode:
SELECT ref, type FROM refs WHERE node_id = ? ORDER BY ref;

selectToolAliasesByNode:
SELECT alias AS alias FROM aliases WHERE node_id = ? ORDER BY alias;

selectToolTagsByNode:
SELECT tag AS tag FROM tags WHERE node_id = ? ORDER BY tag;

Active timestamps in a date range: the scheduled/deadline TEXT columns hold org dates like "2026-09-29" (a prefix of the timestamp string), so lexicographic BETWEEN is correct. file is deliberately NOT projected, callers resolve nothing from it.

sql:tangle ../src/commonMain/sqldelight/computer/whatthefuck/arcology/db/Tools.sq
selectToolScheduledBetween:
SELECT id, title, todo, scheduled, deadline
FROM nodes
WHERE (scheduled BETWEEN ?1 AND ?2) OR (deadline BETWEEN ?1 AND ?2);

Tool-facing timestamp rows in a date range. `date` holds the stamp's YYYY-MM-DD so lexicographic BETWEEN is correct; kind filtering happens in Kotlin over the command's CSV, joined against `nodes` for ID'd rows' titles. Note: `offset` and `file` are SQLDelight-awkward identifiers, so the query aliases `offset AS stamp_offset` (and `file` becomes `file_` in the generated row type; the field names below account for both).

sql:tangle ../src/commonMain/sqldelight/computer/whatthefuck/arcology/db/Tools.sq
selectToolTimestampsBetween:
SELECT t.file AS ts_file, t.pos, t.kind, t.date, t.epoch, t.repeater, t.node_id, t.olp, t.snippet, t."offset" AS stamp_offset, n.title AS node_title, n.todo AS node_todo
FROM tool_timestamps t
LEFT JOIN nodes n ON n.id = t.node_id
WHERE t.date BETWEEN ?1 AND ?2
ORDER BY t.date, t.epoch, t.file, t.pos;

Journal entries across daily files: zero-padded YYYY-MM-DD filenames sort lexicographically in calendar order, so path-prefix + BETWEEN works.

sql:tangle ../src/commonMain/sqldelight/computer/whatthefuck/arcology/db/Tools.sq
selectToolJournalNodesBetween:
SELECT id, title, todo, scheduled, deadline
FROM nodes
WHERE file >= ?1 AND file <= ?2
ORDER BY file, pos;

Tool JSON Envelope

kotlin#+name: tools-json
@Serializable
data class ToolError(val code: String, val message: String)

@Serializable
data class ToolEnvelope(
    val ok: Boolean,
    val data: JsonElement? = null,
    val error: ToolError? = null,
    val risk: RiskBlock? = null
)

/** Stable Json config for every tool's stdout. */
val ToolJson = Json {
    encodeDefaults = true
    prettyPrint = false
    ignoreUnknownKeys = true
}

ToolQueryRepository

The self-contained repository over Tools.sq.

kotlin#+name: tools-repository
data class ChildRecord(
    val id: String, val title: String?, val todo: String?,
    val scheduled: String?, val deadline: String?
)

data class LinkRecord(val targetId: String, val targetTitle: String?, val type: String)

data class RawLinkRecord(val type: String?, val properties: String?)

data class RefRecord(val ref: String, val type: String)

data class TimelineRecord(
    val id: String, val title: String?, val todo: String?,
    val scheduled: String?, val deadline: String?
)

data class VerdictRecord(
    val health: Double, val privateScore: Double, val financial: Double,
    val employer: Double, val political: Double, val createdAt: Long
)

/**
 * One row of tool_timestamps joined to nodes: a timestamp entity
 * (scheduled/deadline/closed/active/inactive/logbook) attached to a
 * heading — ID'd or not. `nodeId`/`nodeTitle`/`nodeTodo` are null for
 * ID-less headings; `olp` and `snippet` carry the harness-facing context.
 */
data class TimelineStampRecord(
    val file: String,
    val pos: Long,
    val kind: String,
    val date: String,
    val epoch: Long?,
    val repeater: String?,
    val nodeId: String?,
    val olp: String?,
    val snippet: String?,
    val offset: Long,
    val nodeTitle: String?,
    val nodeTodo: String?
)

interface ToolQueryRepository {
    suspend fun selectNodeById(id: String): NodeRecord?
    suspend fun selectAncestorTags(id: String): List<String>
    suspend fun selectChildNodes(id: String): List<ChildRecord>
    suspend fun selectDescendantIds(id: String): List<String>
    suspend fun selectLinksFrom(id: String): List<LinkRecord>
    suspend fun selectLinksTo(id: String): List<LinkRecord>
    suspend fun selectRawLinks(id: String): List<RawLinkRecord>
    suspend fun selectRefsByNode(id: String): List<RefRecord>
    suspend fun selectAliasesByNode(id: String): List<String>
    suspend fun selectTagsByNode(id: String): List<String>
    suspend fun selectScheduledBetween(from: String, to: String): List<TimelineRecord>
    suspend fun selectJournalNodesBetween(startFile: String, endFile: String): List<TimelineRecord>
    suspend fun selectTimestampsBetween(from: String, to: String): List<TimelineStampRecord>
    suspend fun insertVerdict(contentHash: String, model: String, scores: RiskScores, createdAt: Long)
    suspend fun selectVerdict(contentHash: String, model: String): VerdictRecord?
}

class ToolQueryRepositoryImpl(
    private val database: ArcologyDatabase
) : ToolQueryRepository {
    @OptIn(ExperimentalCoroutinesApi::class)
    private val dbDispatcher = Dispatchers.IO.limitedParallelism(1)

    override suspend fun selectNodeById(id: String): NodeRecord? = withContext(dbDispatcher) {
        database.toolsQueries.selectToolNodeById(id).executeAsOneOrNull()?.let {
            NodeRecord(it.id, it.level, it.todo, it.priority, it.scheduled, it.deadline, it.title, it.properties, it.olp)
        }
    }

    override suspend fun selectAncestorTags(id: String): List<String> = withContext(dbDispatcher) {
        database.toolsQueries.selectToolAncestorTags(id).executeAsList()
    }

    override suspend fun selectChildNodes(id: String): List<ChildRecord> = withContext(dbDispatcher) {
        database.toolsQueries.selectToolChildNodes(id).executeAsList().map {
            ChildRecord(it.id, it.title, it.todo, it.scheduled, it.deadline)
        }
    }

    override suspend fun selectDescendantIds(id: String): List<String> = withContext(dbDispatcher) {
        database.toolsQueries.selectToolDescendantIds(id).executeAsList()
    }

    override suspend fun selectLinksFrom(id: String): List<LinkRecord> = withContext(dbDispatcher) {
        database.toolsQueries.selectToolLinksFrom(id).executeAsList().map {
            LinkRecord(it.target_id, it.target_title, it.type)
        }
    }

    override suspend fun selectLinksTo(id: String): List<LinkRecord> = withContext(dbDispatcher) {
        database.toolsQueries.selectToolLinksTo(id).executeAsList().map {
            LinkRecord(it.target_id, it.target_title, it.type)
        }
    }

    override suspend fun selectRawLinks(id: String): List<RawLinkRecord> = withContext(dbDispatcher) {
        database.toolsQueries.selectToolRawLinks(id).executeAsList().map { RawLinkRecord(it.type, it.properties) }
    }

    override suspend fun selectRefsByNode(id: String): List<RefRecord> = withContext(dbDispatcher) {
        database.toolsQueries.selectToolRefsByNode(id).executeAsList().map { RefRecord(it.ref, it.type) }
    }

    override suspend fun selectAliasesByNode(id: String): List<String> = withContext(dbDispatcher) {
        database.toolsQueries.selectToolAliasesByNode(id).executeAsList()
    }

    override suspend fun selectTagsByNode(id: String): List<String> = withContext(dbDispatcher) {
        database.toolsQueries.selectToolTagsByNode(id).executeAsList()
    }

    override suspend fun selectScheduledBetween(from: String, to: String): List<TimelineRecord> = withContext(dbDispatcher) {
        database.toolsQueries.selectToolScheduledBetween(from, to).executeAsList().map {
            TimelineRecord(it.id, it.title, it.todo, it.scheduled, it.deadline)
        }
    }

    override suspend fun selectJournalNodesBetween(startFile: String, endFile: String): List<TimelineRecord> = withContext(dbDispatcher) {
        database.toolsQueries.selectToolJournalNodesBetween(startFile, endFile).executeAsList().map {
            TimelineRecord(it.id, it.title, it.todo, it.scheduled, it.deadline)
        }
    }

    override suspend fun selectTimestampsBetween(from: String, to: String): List<TimelineStampRecord> = withContext(dbDispatcher) {
        database.toolsQueries.selectToolTimestampsBetween(from, to).executeAsList().map {
            TimelineStampRecord(
                it.ts_file, it.pos, it.kind, it.date, it.epoch, it.repeater,
                it.node_id, it.olp, it.snippet, it.stamp_offset, it.node_title, it.node_todo
            )
        }
    }

    override suspend fun insertVerdict(contentHash: String, model: String, scores: RiskScores, createdAt: Long) = withContext(dbDispatcher) {
        database.toolsQueries.insertVerdict(
            contentHash, model, scores.health, scores.privateScore, scores.financial,
            scores.employer, scores.political, createdAt
        )
    }

    override suspend fun selectVerdict(contentHash: String, model: String): VerdictRecord? = withContext(dbDispatcher) {
        database.toolsQueries.selectVerdict(contentHash, model).executeAsOneOrNull()?.let {
            VerdictRecord(it.health, it.private_score, it.financial, it.employer, it.political, it.created_at)
        }
    }
}

NodeRecord itself is defined in the note-get block alongside its only constructor call site, keeping type definition adjacent to use.

TimestampsIndexerPlugin

Harvests all timestamp entities the parser produces into tool_timestamps (Tools.sq):

  • Planning lines SCHEDULED DEADLINE CLOSED from section.heading.planningInfo (kinds scheduled, deadline, closed).

  • LOGBOOK drawer StateChange entries, kind logbook; date is the entry's day in the host's local zone (the logbook parser already reads Emacs-written stamps as local time); epoch the entry second; repeater null; snippet null — the State "X" from "Y" fact itself is the row's content.

  • Inline stamps in heading titles (walked from title.items) and body chunks — paragraphs, list items, quote/aside/edits/page-intro/center/video blocks, recursion through formatted spans; DTStamp plus both ends of DTRange; active and inactive alike (kinds active / inactive).

Positions follow agenda/indexer.org's convention: pos is the heading's char offset (token ranges are absolute file offsets thanks to parseWithDetails, so no base arithmetic), offset is the stamp's own char offset. File-level stamps (preamble properties like ARCOLOGY_EXPIRE, preface text) anchor to =pos=0=, node_id the file's :ID:=, olp empty.

Snippet spec: anchor a stamp char offset; current line [l₁, l₂). Span = min(l₁, a−100) to max(l₂, a+100) — the whole current line, extended ≤100 chars backward and forward past its ends — except on heading-line anchors, where backward extension is disabled: the heading line is its own context, and reaching back would cross the section boundary into the previous sibling's body. All snippets additionally never cross a heading line (which also guarantees excluded subtrees can't leak through a neighbor's context), whitespace collapses, hard cap 240 chars with ….

Exclusion: the walk checks each heading's own tags against EXCLUDE_TAG_SET (noexport/NOEXPORT/ARCHIVE — same set the tools read) and drops the subtree wholesale, so excluded rows never reach the DB and the tool query needs no read-time tag check. ParseResult.Success with null document (blank-content path) is a no-op. The class runs per-file inside the indexer's plugin hooks; exceptions are logged, never fatal to indexing.

kotlin#+name: tools-timestamps-plugin:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/llm/TimestampsIndexerPlugin.kt
package computer.whatthefuck.arcology.llm

import computer.whatthefuck.arcology.db.ArcologyDatabase
import computer.whatthefuck.arcology.indexer.IndexerPlugin
import computer.whatthefuck.arcology.parser.ParseResult
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import xyz.lepisma.orgmode.OrgBlock
import xyz.lepisma.orgmode.OrgChunk
import xyz.lepisma.orgmode.OrgInlineElem
import xyz.lepisma.orgmode.OrgList
import xyz.lepisma.orgmode.OrgLogbookEntry
import xyz.lepisma.orgmode.OrgSection
import xyz.lepisma.orgmode.plainText

/**
 ,* Tags that suppress a note from tool output, mirroring the publishing
 ,* layer's exclusions. Case-sensitive on purpose: :ARCHIVE: hides,
 ,* :Archive: (a knowledge-organization tag) does not. Lives in the
 ,* commonMain plugin target so the jvmMain tools and the plugin share
 ,* one definition.
 ,*/
val EXCLUDE_TAG_SET = setOf("noexport", "NOEXPORT", "ARCHIVE")

/**
 ,* Harvests every timestamp entity in the parsed document — planning
 ,* lines, LOGBOOK entries, inline active/inactive stamps (heading titles
 ,* and body chunks, DTStamp and DTRange ends) — into `tool_timestamps`,
 ,* keyed (file, heading-pos, kind, stamp-offset). Rows carry node_id/olp
 ,* when the heading has an :ID:, plus a ±100-char line-anchored snippet.
 ,* Excluded-tag subtrees (noexport/NOEXPORT/ARCHIVE) are dropped
 ,* wholesale. Per-file delete-then-insert; onFileRemoved cleans up.
 ,*/
class TimestampsIndexerPlugin(
    database: ArcologyDatabase,
    private val log: (String) -> Unit = {}
) : IndexerPlugin {
    private val queries = database.toolsQueries
    private val zone = TimeZone.currentSystemDefault()

    override suspend fun onFileIndexed(result: ParseResult.Success) {
        try {
            harvest(result)
        } catch (e: Exception) {
            log("TimestampsIndexerPlugin: harvest failed for ${result.file.path}: ${e.message}")
        }
    }

    override suspend fun onFileRemoved(filePath: String) {
        try {
            queries.deleteToolTimestampsByFile(filePath)
        } catch (e: Exception) {
            log("TimestampsIndexerPlugin: remove failed for $filePath: ${e.message}")
        }
    }

    /** EXCLUDE_TAG semantics mirrored from the tool layer's isExcludedHeading. */
    private fun excludedHeading(heading: xyz.lepisma.orgmode.OrgHeading): Boolean {
        val tags = heading.tags?.tags ?: return false
        return tags.any { it in EXCLUDE_TAG_SET }
    }

    private suspend fun harvest(result: ParseResult.Success) {
        queries.deleteToolTimestampsByFile(result.file.path)
        val document = result.document ?: return // blank-content early-return path
        val content = result.content
        val lineStarts = computeLineStarts(content)

        // Every heading line in the file — excluded ones included — acts as
        // a hard snippet boundary; stamps inside excluded subtrees are
        // dropped by the walk itself, never by boundary checks.
        val headings = mutableListOf<Int>()
        fun collectHeadingPositions(sections: List<OrgSection>) {
            sections.forEach { section ->
                val headingTokens = section.heading.tokens
                if (headingTokens.isNotEmpty()) headings.add(headingTokens.first().range.first)
                collectHeadingPositions(section.body.filterIsInstance<OrgSection>())
            }
        }
        collectHeadingPositions(document.content)
        headings.sort()

        val fileLevelId = document.preamble.properties?.map?.get("ID")
            ?.let { line -> line.items.filterIsInstance<OrgInlineElem.Text>()
                .joinToString("") { it.text }.trim() }
            ?.takeIf { it.isNotEmpty() }

        // Preamble property values (e.g. ARCOLOGY_EXPIRE): pos 0, file-level node.
        val preambleStamps = mutableListOf<OrgInlineElem.DTStamp>()
        document.preamble.properties?.map?.forEach { (_, line) ->
            line.items.filterIsInstance<OrgInlineElem.DTStamp>().forEach { preambleStamps.add(it) }
        }

        collectFromChunks(document.preface.body, result.file.path, 0L,
                          fileLevelId, "", content, lineStarts, headings)
        preambleStamps.forEach { stamp ->
            insertStamp(stamp, result.file.path, 0L, if (stamp.isActive) "active" else "inactive",
                        fileLevelId, "", content, lineStarts, headings)
        }

        document.content.forEach { section ->
            walkSection(section, result.file.path, emptyList(), false, content, lineStarts, headings)
        }
    }

    private suspend fun walkSection(
        section: OrgSection,
        file: String,
        parentPath: List<String>,
        parentExcluded: Boolean,
        content: String,
        lineStarts: List<Int>,
        headings: List<Int>
    ) {
        val heading = section.heading
        val title = heading.title.plainText().trim()
        if (parentExcluded || excludedHeading(heading)) return

        val pos = heading.tokens.first().range.first
        val nodeId = heading.properties?.map?.get("ID")
            ?.let { line -> line.items.filterIsInstance<OrgInlineElem.Text>()
                .joinToString("") { it.text }.trim() }
            ?.takeIf { it.isNotEmpty() }
        val olp = (parentPath + title).joinToString("/")

        heading.planningInfo?.let { planning ->
            planning.scheduled?.let { insertStamp(it, file, pos.toLong(), "scheduled", nodeId, olp, content, lineStarts, headings) }
            planning.deadline?.let { insertStamp(it, file, pos.toLong(), "deadline", nodeId, olp, content, lineStarts, headings) }
            planning.closed?.let { insertStamp(it, file, pos.toLong(), "closed", nodeId, olp, content, lineStarts, headings) }
        }

        heading.title.items.filterIsInstance<OrgInlineElem.DTStamp>().forEach { stamp ->
            insertStamp(stamp, file, pos.toLong(), if (stamp.isActive) "active" else "inactive",
                        nodeId, olp, content, lineStarts, headings)
        }

        section.body.filterIsInstance<OrgChunk.OrgLogbookDrawer>().forEach { drawer ->
            drawer.entries.filterIsInstance<OrgLogbookEntry.StateChange>().forEach { change ->
                val day = change.timestamp.toLocalDateTime(zone).date.toString()
                // Composite-key dedupe: multiple transitions on one day
                // collapse to the last one — state history is agenda's
                // job (task_state_history); we only need presence.
                queries.insertToolTimestamp(
                    file, pos.toLong(), "logbook", day,
                    change.timestamp.epochSeconds.toLong(), null,
                    nodeId, olp, null, 0L
                )
            }
        }

        collectFromChunks(
            section.body.filter { it !is OrgSection }, file, pos.toLong(), nodeId, olp,
            content, lineStarts, headings
        )

        section.body.filterIsInstance<OrgSection>().forEach { child ->
            walkSection(child, file, parentPath + title, false, content, lineStarts, headings)
        }
    }

    private suspend fun collectFromChunks(
        chunks: List<OrgChunk>,
        file: String,
        pos: Long,
        nodeId: String?,
        olp: String,
        content: String,
        lineStarts: List<Int>,
        headings: List<Int>
    ) {
        chunks.forEach { chunk ->
            collectChunkStamps(chunk).forEach { stamp ->
                insertStamp(stamp, file, pos, if (stamp.isActive) "active" else "inactive",
                            nodeId, olp, content, lineStarts, headings)
            }
        }
    }

    /**
     ,* Every DTStamp inside a chunk, recursing into wrapper chunks and
     ,* formatted spans. Token ranges are absolute file offsets, so the
     ,* snippet anchor needs no base-offset arithmetic.
     ,*/
    private fun collectChunkStamps(chunk: OrgChunk): List<OrgInlineElem.DTStamp> = buildList {
        fun fromInline(elems: List<OrgInlineElem>) {
            elems.forEach { elem ->
                when (elem) {
                    is OrgInlineElem.DTStamp -> add(elem)
                    is OrgInlineElem.DTRange -> { add(elem.start); add(elem.end) }
                    is OrgInlineElem.Bold -> fromInline(elem.content)
                    is OrgInlineElem.Italic -> fromInline(elem.content)
                    is OrgInlineElem.Underline -> fromInline(elem.content)
                    is OrgInlineElem.StrikeThrough -> fromInline(elem.content)
                    is OrgInlineElem.Verbatim -> fromInline(elem.content)
                    is OrgInlineElem.Code -> fromInline(elem.content)
                    else -> {}
                }
            }
        }
        when (chunk) {
            is OrgChunk.OrgParagraph -> fromInline(chunk.items)
            is OrgList.OrgUnorderedList -> chunk.items.forEach { item ->
                item.content.forEach { sub -> addAll(collectChunkStamps(sub)) }
            }
            is OrgList.OrgOrderedList -> chunk.items.forEach { item ->
                item.content.forEach { sub -> addAll(collectChunkStamps(sub)) }
            }
            is OrgBlock.OrgQuoteBlock -> chunk.body.forEach { addAll(collectChunkStamps(it)) }
            is OrgBlock.OrgAsideBlock -> chunk.body.forEach { addAll(collectChunkStamps(it)) }
            is OrgBlock.OrgEditsBlock -> chunk.body.forEach { addAll(collectChunkStamps(it)) }
            is OrgBlock.OrgPageIntroBlock -> chunk.body.forEach { addAll(collectChunkStamps(it)) }
            is OrgBlock.OrgCenterBlock -> chunk.body.forEach { addAll(collectChunkStamps(it)) }
            is OrgBlock.OrgVideoBlock -> chunk.body.forEach { addAll(collectChunkStamps(it)) }
            else -> {} // src/example/html: timestamps there are not prose dates
        }
    }

    /** anchor = the stamp's token start; the snippet walks ±100 chars. */
    private suspend fun insertStamp(
        stamp: OrgInlineElem.DTStamp,
        file: String,
        pos: Long,
        kind: String,
        nodeId: String?,
        olp: String,
        content: String,
        lineStarts: List<Int>,
        headings: List<Int>
    ) {
        val anchor = stamp.tokens.firstOrNull()?.range?.first ?: pos.toInt()
        val day = stamp.date.toString()
        val epoch = stamp.time?.let { (start, _) ->
            kotlinx.datetime.LocalDateTime(stamp.date, start)
                .toInstant(TimeZone.currentSystemDefault()).epochSeconds.toLong()
        }
        queries.insertToolTimestamp(
            file, pos, kind, day, epoch, stamp.repeater,
            nodeId, olp, snippet(content, lineStarts, anchor, headings), anchor.toLong()
        )
    }

    /** Line-start indices for snippet anchoring. */
    private fun computeLineStarts(content: String): List<Int> {
        val starts = mutableListOf(0)
        var idx = content.indexOf('\n')
        while (idx != -1) {
            starts.add(idx + 1)
            idx = content.indexOf('\n', idx + 1)
        }
        return starts
    }

    /**
     ,* Snippet: whole line at [anchor], extended ≤100 chars backward and
     ,* forward past its ends — but never across a heading line:
     ,* headings are hard context boundaries, which also guarantees an
     ,* excluded heading's contents never leak through a neighbor's
     ,* snippet (excluded subtrees contribute no rows at all). When the
     ,* anchor itself sits ON a heading line, backward extension is
     ,* disabled wholly — the line is its own context, and reaching back
     ,* would cross the section boundary into the previous sibling's
     ,* body. Forward extension stays: it reaches into the anchor heading's
     ,* own body, still clamped by the next heading line. Collapsed
     ,* whitespace, 240-char cap with ….
     ,*/
    internal fun snippet(content: String, lineStarts: List<Int>, anchor: Int, headings: List<Int>): String {
        if (content.isEmpty()) return ""
        val a = anchor.coerceIn(0, content.length - 1)
        val lineIdx = lineStarts.binarySearch(a).let { if (it >= 0) it else -it - 2 }.coerceAtLeast(0)
        val lineStart = lineStarts[lineIdx]
        val lineEnd = content.indexOf('\n', a).let { if (it == -1) content.length else it }
        val prevHeading = headings.lastOrNull { it < lineStart }
        val leftHard = prevHeading?.let { content.indexOf('\n', it).let { e -> if (e == -1) content.length else e } + 1 }
            ?: 0
        val nextHeading = headings.firstOrNull { it > lineStart }
        val rightHard = nextHeading ?: content.length
        val onHeadingLine = headings.binarySearch(lineStart) >= 0
        val start = if (onHeadingLine) {
            lineStart
        } else {
            maxOf(leftHard, minOf(lineStart, a - 100)).coerceIn(0, content.length)
        }
        val end = minOf(rightHard, maxOf(lineEnd, a + 100)).coerceIn(0, content.length)
        val collapsed = content.substring(start, end).replace(Regex("\\s+"), " ").trim()
        return if (collapsed.length > 240) collapsed.take(239) + "…" else collapsed
    }
}

The Composition

All named blocks assemble into one target. Package and imports live once in tools-imports; the individual blocks carry code only, keeping the noweb composition Kotlin-legal (exactly one package statement per file).

kotlin#+name: tools-imports
package computer.whatthefuck.arcology.llm

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.CliktError
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.multiple
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.required
import com.github.ajalt.clikt.parameters.types.double
import com.github.ajalt.clikt.parameters.types.int
import com.github.ajalt.clikt.parameters.types.long
import computer.whatthefuck.arcology.capture.CaptureService
import computer.whatthefuck.arcology.db.ArcologyDatabase
import computer.whatthefuck.arcology.database.DatabaseFactory
import computer.whatthefuck.arcology.database.RoamRepositoryImpl
import computer.whatthefuck.arcology.indexer.FileIndexResult
import computer.whatthefuck.arcology.indexer.IndexResult
import computer.whatthefuck.arcology.indexer.FileSystemInterface
import computer.whatthefuck.arcology.indexer.IndexingConfig
import computer.whatthefuck.arcology.indexer.JvmFileSystem
import computer.whatthefuck.arcology.indexer.createIndexingService
import computer.whatthefuck.arcology.parser.OrgFileParser
import computer.whatthefuck.arcology.parser.ParseResult
import computer.whatthefuck.arcology.search.SearchService
import io.ktor.client.*
import io.ktor.client.plugins.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import xyz.lepisma.orgmode.plainText
import java.io.File
import java.sql.DriverManager
import java.util.Properties
import kotlin.time.Clock
import kotlin.time.Instant
kotlin#+name: tools-composed:tangle ../src/jvmMain/kotlin/computer/whatthefuck/arcology/llm/NoteTools.kt:noweb yes
<<tools-imports>>

<<tools-notes-parent>>

<<tools-json>>

<<tools-repository>>

<<tools-base-command>>

<<tools-note-get>>

<<tools-body-renderer>>

<<tools-body-loader>>

<<tools-note-get-command>>

<<tools-note-search>>

<<tools-note-timeline>>

<<tools-note-todo>>

<<tools-note-capture>>

<<tools-capture-daily-header>>

<<tools-note-sql>>

<<tools-sql-runtime>>

<<tools-note-reindex>>

The TimestampsIndexerPlugin is a standalone tangle target (TimestampsIndexerPlugin.kt) — it must not ride the NoteTools.kt composition because it carries its own package/imports.

Tests

Envelope serialization, repository round-trips, exclusion-tag ancestor semantics, and the position-bracket child query — in-memory DB, no filesystem, no network (the classifier is covered by classifier.org's own suite).

kotlin#+name: tools-tests:tangle ../src/jvmTest/kotlin/computer/whatthefuck/arcology/llm/NoteToolsTest.kt
package computer.whatthefuck.arcology.llm

import computer.whatthefuck.arcology.db.ArcologyDatabase
import computer.whatthefuck.arcology.indexer.JvmFileSystem
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.*
import kotlin.io.path.writeText
import kotlin.io.path.absolutePathString
import kotlin.test.*

class NoteToolsTest {

    private fun makeDatabase(): ArcologyDatabase =
        computer.whatthefuck.arcology.database.DatabaseFactory.createInMemoryDatabase()

    /** Seed: root heading, child under it, excluded-tagged sibling, a link. */
    private suspend fun seed(db: ArcologyDatabase) {
        val q = db.arcologyDatabaseQueries
        q.insertFile("/d/notes.org", "notes file", "hash1", 0L, 0L)
        q.insertNode("root", "/d/notes.org", 1, 10, null, null, null, null, "Project Root", "", "Project Root")
        q.insertNode("child-a", "/d/notes.org", 2, 20, null, null, null, null, "Child A", "", "Project Root/Child A")
        q.insertNode("child-b", "/d/notes.org", 2, 30, null, null, null, null, "Child B", "", "Project Root/Child B")
        q.insertNode("unrelated", "/d/notes.org", 1, 40, null, null, null, null, "Unrelated Top", "", "Unrelated Top")
        q.insertNodeAncestor("root", "root")
        q.insertNodeAncestor("child-a", "child-a")
        q.insertNodeAncestor("child-a", "root")
        q.insertNodeAncestor("child-b", "child-b")
        q.insertNodeAncestor("child-b", "root")
        q.insertNodeAncestor("unrelated", "unrelated")
        q.insertTag("child-b", "NOEXPORT")
        q.insertLink(1L, "child-a", "root", "internal", "")
        q.insertTag("root", "project")
    }

    @Test
    fun `node projection omits file column`() = runTest {
        val db = makeDatabase(); seed(db)
        val row = db.toolsQueries.selectToolNodeById("root").executeAsOneOrNull()
        assertEquals("Project Root", row?.title)
        // selectToolNodeById has no `file` column at all — compile-level guarantee.
    }

    @Test
    fun `bracket bounds children and descendants`() = runTest {
        val db = makeDatabase(); seed(db)
        val root = db.toolsQueries.selectToolNodeBracket("root").executeAsOne()
        assertEquals(10L, root.my_pos)
        assertEquals(40L, root.bracket_end) // next level<=1 node after root

        val children = db.toolsQueries.selectToolChildNodes("root")
            .executeAsList().map { it.id }
        // child-b is NOEXPORT-tagged but still enumerable as structure;
        // exclusion filtering happens at the repository/command layer.
        assertEquals(listOf("child-a", "child-b"), children)

        val unrelated = db.toolsQueries.selectToolNodeBracket("unrelated").executeAsOne()
        assertEquals(40L, unrelated.my_pos)
        assertNull(unrelated.bracket_end)
        assertEquals(
            emptyList(),
            db.toolsQueries.selectToolChildNodes("unrelated").executeAsList()
        )
    }

    @Test
    fun `ancestor tags include inherited chain`() = runTest {
        val db = makeDatabase(); seed(db)
        val childTags = db.toolsQueries.selectToolAncestorTags("child-a").executeAsList()
        assertTrue("NOEXPORT" !in childTags)
        assertTrue("project" in childTags) // inherited from root

        val excludedTags = db.toolsQueries.selectToolAncestorTags("child-b").executeAsList()
        assertTrue("NOEXPORT" in excludedTags)
    }

    @Test
    fun `scheduled between matches lexicographic dates`() = runTest {
        val db = makeDatabase(); seed(db)
        db.arcologyDatabaseQueries.insertNode(
            "task-1", "/d/notes.org", 1, 50, null, null, "2026-09-28", null, "Task One", "", "Task One"
        )
        db.arcologyDatabaseQueries.insertNode(
            "task-2", "/d/notes.org", 1, 60, null, null, null, "2026-10-02", "Task Two", "", "Task Two"
        )
        val repo = ToolQueryRepositoryImpl(db)
        val hits = repo.selectScheduledBetween("2026-09-01", "2026-09-30")
        assertEquals(listOf("task-1"), hits.map { it.id })
    }

    @Test
    fun `timestamps between joins node titles for id'd rows`() = runTest {
        val db = makeDatabase()
        db.arcologyDatabaseQueries.insertFile("/d/ts.org", "ts file", "h1", 0L, 0L)
        db.arcologyDatabaseQueries.insertNode(
            "ts-node", "/d/ts.org", 1, 10, "TODO", null, null, null, "Stamped Task", "", "Stamped Task"
        )
        val q = db.toolsQueries
        q.insertToolTimestamp("/d/ts.org", 10L, "scheduled", "2026-07-31", 0L, null,
                              "ts-node", "Stamped Task", "SCHEDULED: <2026-07-31>", 0L)
        // ID-less row: node_id null → no title join
        q.insertToolTimestamp("/d/ts.org", 40L, "active", "2026-08-01", 0L, null,
                              null, "Meeting notes", "Meeting at <2026-08-01>", 0L)
        val repo = ToolQueryRepositoryImpl(db)
        val rows = repo.selectTimestampsBetween("2026-07-01", "2026-08-31")
        assertEquals(2, rows.size)
        val idd = rows.first { it.nodeId == "ts-node" }
        assertEquals("Stamped Task", idd.nodeTitle)
        assertEquals("TODO", idd.nodeTodo)
        assertEquals("scheduled", idd.kind)
        val idless = rows.first { it.nodeId == null }
        assertNull(idless.nodeTitle)
        assertEquals("active", idless.kind)
        // Ordered by date regardless of kind
        assertEquals(listOf("2026-07-31", "2026-08-01"), rows.map { it.date })
    }

    @Test
    fun `journal between scopes to daily files`() = runTest {
        val db = makeDatabase(); seed(db)
        db.arcologyDatabaseQueries.insertFile("journals/2026-09-28.org", "journal 28", "h2", 0L, 0L)
        db.arcologyDatabaseQueries.insertNode(
            "entry-1", "journals/2026-09-28.org", 1, 10, null, null, null, null, "Morning entry", "", "Morning entry"
        )
        val repo = ToolQueryRepositoryImpl(db)
        val between = repo.selectJournalNodesBetween("journals/2026-09-01.org", "journals/2026-09-30.org")
        assertEquals(listOf("entry-1"), between.map { it.id })
    }

    @Test
    fun `verdict cache roundtrip and model scoping`() = runTest {
        val repo = ToolQueryRepositoryImpl(makeDatabase())
        repo.insertVerdict("hash1", "llama", RiskScores(health = 0.9), 42L)
        assertEquals(0.9, repo.selectVerdict("hash1", "llama")?.health)
        assertNull(repo.selectVerdict("hash1", "other-model"))
    }

    @Test
    fun `envelope serializes ok and defaults`() {
        val json = ToolJson.encodeToString(
            ToolEnvelope.serializer(),
            ToolEnvelope(ok = true, data = buildJsonObject { put("x", 1) })
        )
        assertTrue(json.contains("\"ok\":true"))
        // encodeDefaults forces explicit risk fields even when null-safe
        assertTrue(json.contains("\"data\""))
    }

    @Test
    fun `parse properties string`() {
        val obj = parsePropertiesString("CATEGORY: project\nID: root")
        assertEquals("project", obj["CATEGORY"]?.jsonPrimitive?.content)
        assertEquals("root", obj["ID"]?.jsonPrimitive?.content)
    }

    // ─── loadLeafBody: document-AST rendering ───────────────────────────
    //
    // Fixture mirrors the file-level-node shape from the planning session:
    // preamble ID, preface text, ID-less headings (inline), a noexport
    // subtree, an ID+noexport heading, and an ID-bearing non-excluded
    // sibling (stub).

    private val fixtureOrg = """
        :PROPERTIES:
        :ID: The_Id
        :END:
        #+TITLE: THe Title

        Some preamble text

        * ID-less heading 1

        bla

        * ID-less heading 2

        ble bla

        * a final heading :noexport:

        some text.,,, under the noexport!

        * heading with ID :ATTACH:noexport:

        this is here to capture file attachments

        * fetchable child
        :PROPERTIES:
        :ID: child-fetchable
        :END:

        child body content
    """.trimIndent()

    private suspend fun seedBodyFixture(): Triple<ArcologyDatabase, java.nio.file.Path, java.nio.file.Path> {
        val tmp = kotlin.io.path.createTempDirectory(prefix = "arcology-bodytest-")
        val orgDir = tmp.resolve("org")
        java.nio.file.Files.createDirectories(orgDir)
        orgDir.resolve("fixture.org").writeText(fixtureOrg)

        val db = makeDatabase()
        db.arcologyDatabaseQueries.insertFile("fixture.org", "The Title", "h1", 0L, 0L)
        db.arcologyDatabaseQueries.insertNode(
            "The_Id", "fixture.org", 0, 0, null, null, null, null, "The Title", "ID: The_Id", ""
        )
        db.arcologyDatabaseQueries.insertNode(
            "child-fetchable", "fixture.org", 1, 10, null, null, null, null,
            "fetchable child", "ID: child-fetchable", "The Title/fetchable child"
        )
        return Triple(db, tmp, orgDir)
    }

    @Test
    fun `file-level body includes preamble and ID-less heading text inline`() = runTest {
        val (db, _, orgDir) = seedBodyFixture()
        val body = loadLeafBody(db, "The_Id", orgDir.toString(), maxBytes = 65536)!!
        assertTrue(body.contains("Some preamble text"), "preamble must render: $body")
        assertTrue(body.contains("bla"), "ID-less heading 1 text inline: $body")
        assertTrue(body.contains("ble bla"), "ID-less heading 2 text inline: $body")
        assertTrue(body.contains("* ID-less heading 1"), "ID-less title line with stars: $body")
        assertTrue(body.contains("* ID-less heading 2"), "ID-less title line with stars: $body")
    }

    @Test
    fun `file-level body elides noexport subtree text`() = runTest {
        val (db, _, orgDir) = seedBodyFixture()
        val body = loadLeafBody(db, "The_Id", orgDir.toString(), maxBytes = 65536)!!
        assertFalse(body.contains("under the noexport"), "noexport text must vanish: $body")
        assertFalse(body.contains("this is here to capture file attachments"), "ATTACH:noexport text must vanish: $body")
        assertFalse(body.contains("heading with ID"), "excluded ID-bearing heading leaves no stub: $body")
        assertFalse(body.contains("a final heading"), "excluded heading leaves no stub: $body")
        assertFalse(body.contains("child body content"), "non-excluded ID-bearing sibling is body of its own node, not parent's: $body")
    }

    @Test
    fun `file-level body stubs ID-bearing child headings`() = runTest {
        val (db, _, orgDir) = seedBodyFixture()
        val body = loadLeafBody(db, "The_Id", orgDir.toString(), maxBytes = 65536)!!
        assertTrue(body.contains("* fetchable child [id:child-fetchable]"), "stub line for discovery: $body")
    }

    @Test
    fun `ID-bearing leaf body is own text only, siblings excluded`() = runTest {
        val (db, _, orgDir) = seedBodyFixture()
        val body = loadLeafBody(db, "child-fetchable", orgDir.toString(), maxBytes = 65536)!!
        assertTrue(body.contains("child body content"), "own text: $body")
        assertFalse(body.contains("Some preamble text"), "no preamble leakage: $body")
        assertFalse(body.contains("ble bla"), "no sibling text leakage: $body")
    }

    @Test
    fun `body loader truncates oversized output`() = runTest {
        val tmp = kotlin.io.path.createTempDirectory(prefix = "arcology-bodytest-")
        val huge = (1..500).joinToString("\n") { "line $it filler text" }
        tmp.resolve("big.org").writeText(":PROPERTIES:\n:ID: big_id\n:END:\n\n$huge\n")
        val db = makeDatabase()
        db.arcologyDatabaseQueries.insertFile("big.org", "big", "h", 0L, 0L)
        db.arcologyDatabaseQueries.insertNode(
            "big_id", "big.org", 0, 0, null, null, null, null, "big", "ID: big_id", ""
        )
        val body = loadLeafBody(db, "big_id", tmp.toString(), maxBytes = 200)!!
        assertTrue(body.contains("[truncated at 200 bytes]"))
        assertTrue(body.length < 400)
    }

    @Test
    fun `missing file body returns null`() = runTest {
        val db = makeDatabase()
        db.arcologyDatabaseQueries.insertFile("nowhere.org", "nowhere", "h", 0L, 0L)
        db.arcologyDatabaseQueries.insertNode(
            "ghost", "nowhere.org", 0, 0, null, null, null, null, "ghost", "", ""
        )
        assertNull(loadLeafBody(db, "ghost", kotlin.io.path.createTempDirectory(prefix = "arcology-bodytest-").toString(), maxBytes = 65536))
    }

    // ─── ensureDailyFileHeader: level-0 node creation for CLI-only days ──

    @Test
    fun `ensureDailyFileHeader writes builtin level-0 header for missing journal`() = runTest {
        val tmp = kotlin.io.path.createTempDirectory(prefix = "arcology-dailyhdr-")
        val fs = JvmFileSystem(tmp)
        val rel = "journals/2026-10-20.org"
        // appendToFile creates no missing parents (true of the current CLI
        // too); the org dir always has journals/ present.
        java.nio.file.Files.createDirectories(tmp.resolve("journals"))
        assertFalse(fs.fileExists(rel))

        ensureDailyFileHeader(fs, rel, kotlinx.datetime.LocalDateTime(2026, 10, 20, 9, 0))

        assertTrue(fs.fileExists(rel))
        val content = fs.readFile(rel)
        assertTrue(
            content.startsWith(":PROPERTIES:\n:ID:       "),
            "level-0 identity drawer leads the new file -- $content"
        )
        assertContains(content, "#+title: 2026-10-20")
        // The generated ID must carry the target date so the file-level
        // node is queryable against the journal day.
        val idLine = content.lineSequence().first { it.trimStart().startsWith(":ID:") }
        assertContains(idLine, "20261020T09")
    }

    @Test
    fun `ensureDailyFileHeader leaves existing file untouched`() = runTest {
        val tmp = kotlin.io.path.createTempDirectory(prefix = "arcology-dailyhdr-")
        val fs = JvmFileSystem(tmp)
        val rel = "journals/2026-10-20.org"
        java.nio.file.Files.createDirectories(tmp.resolve("journals"))
        fs.appendToFile(rel, ":PROPERTIES:\n:ID:       custom\n:END:\n")

        ensureDailyFileHeader(fs, rel, kotlinx.datetime.LocalDateTime(2026, 10, 20, 9, 0))

        assertEquals(":PROPERTIES:\n:ID:       custom\n:END:\n", fs.readFile(rel))
    }

    @Test
    fun `ensureDailyFileHeader header matches built-in daily file pattern`() = runTest {
        val tmp = kotlin.io.path.createTempDirectory(prefix = "arcology-dailyhdr-")
        val fs = JvmFileSystem(tmp)
        val rel = "journals/2026-10-20.org"
        java.nio.file.Files.createDirectories(tmp.resolve("journals"))
        val dt = kotlinx.datetime.LocalDateTime(2026, 10, 20, 9, 0)

        ensureDailyFileHeader(fs, rel, dt)

        val content = fs.readFile(rel)
        assertEquals(
            computer.whatthefuck.arcology.capture.CaptureService
                .buildDailyFileContent(dt, content.trimIndent().lineSequence().first { it.trimStart().startsWith(":ID:") }.substringAfter(":ID:").trim()),
            content
        )
    }

    // ─── capture --ref: ROAM_REFS round-trip through capture and index ──
    //
    // The parser lifts heading properties only from ID-bearing nodes, so
    // both tests below capture with an ID (what --ref implies at the CLI).

    /**
     * Journal path: a capture entry built by CaptureService with refs,
     * appended to a daily file and indexed, must land its ROAM_REFS in
     * the refs table keyed by the new node's ID — the rows =notes get=
     * and the published pages' external-reference sections read back.
     */
    @Test
    fun `capture entry refs index into refs table for journal capture`() = runTest {
        val tmp = kotlin.io.path.createTempDirectory(prefix = "arcology-capref-")
        val orgDir = tmp.resolve("org")
        java.nio.file.Files.createDirectories(orgDir.resolve("journals"))
        val dbPath = tmp.resolve("test.db").absolutePathString()
        val rel = "journals/2026-10-21.org"
        val captureId = "20261021T090000.000000"

        val entry = computer.whatthefuck.arcology.capture.CaptureService.buildCaptureEntry(
            body = "reading a page",
            title = "Web page note",
            refs = listOf("https://example.com/post", "https://other.org/page"),
            id = captureId,
            time = kotlinx.datetime.LocalTime(9, 0)
        )
        val fs = JvmFileSystem(orgDir)
        ensureDailyFileHeader(fs, rel, kotlinx.datetime.LocalDateTime(2026, 10, 21, 9, 0))
        fs.appendToFile(rel, "\n" + entry)

        val indexer = computer.whatthefuck.arcology.indexer.createIndexingService(
            databasePath = dbPath,
            config = computer.whatthefuck.arcology.indexer.IndexingConfig(
                batchSize = 1, memoryMonitoring = false, enableFtsDefer = false,
                exportDatabaseAfterIndexing = false
            ),
            basePath = orgDir.toString()
        )
        val result = indexer.indexFile(rel)
        assertTrue(
            result is computer.whatthefuck.arcology.indexer.FileIndexResult.Success,
            "journal file must index: $result"
        )

        val db = computer.whatthefuck.arcology.database.DatabaseFactory.createDatabase(dbPath)
        val rows = db.toolsQueries.selectToolRefsByNode(captureId).executeAsList()
        assertEquals(
            listOf("https://example.com/post", "https://other.org/page"),
            rows.map { it.ref }
        )
        assertTrue(rows.all { it.type == "http" }, "ref rows carry the parser's http type: $rows")
    }

    /**
     * --heading path: appendHeadingUnder demotes the entry under the
     * target, persistAndReindex re-parses the file, and the child's
     * ROAM_REFS still reach the refs table under the child's ID — the
     * properties drawer must survive the level surgery.
     */
    @Test
    fun `capture refs under heading survive appendHeadingUnder and reindex`() = runTest {
        val tmp = kotlin.io.path.createTempDirectory(prefix = "arcology-capref-heading-")
        val orgDir = tmp.resolve("org")
        java.nio.file.Files.createDirectories(orgDir)
        val dbPath = tmp.resolve("test.db").absolutePathString()
        orgDir.resolve("seed.org").writeText(
            """
            :PROPERTIES:
            :ID:       seed-file-id
            :END:
            #+title: Seed File

            * Target heading
            :PROPERTIES:
            :ID:       target-id
            :END:

            Target body.
            """.trimIndent()
        )

        val indexer = computer.whatthefuck.arcology.indexer.createIndexingService(
            databasePath = dbPath,
            config = computer.whatthefuck.arcology.indexer.IndexingConfig(
                batchSize = 1, memoryMonitoring = false, enableFtsDefer = false,
                exportDatabaseAfterIndexing = false
            ),
            basePath = orgDir.toString()
        )
        val seedResult = indexer.indexFile("seed.org")
        assertTrue(
            seedResult is computer.whatthefuck.arcology.indexer.FileIndexResult.Success,
            "seed file must index: $seedResult"
        )

        val captureId = "20261021T091500.000000"
        val entry = computer.whatthefuck.arcology.capture.CaptureService.buildCaptureEntry(
            body = "child capture with a source page",
            title = "Child capture",
            refs = listOf("https://child.example.com/source"),
            id = captureId,
            time = kotlinx.datetime.LocalTime(9, 15)
        )

        val db = computer.whatthefuck.arcology.database.DatabaseFactory.createDatabase(dbPath)
        val editor = computer.whatthefuck.arcology.editor.OrgDocumentEditor(
            fileSystem = JvmFileSystem(orgDir),
            repository = computer.whatthefuck.arcology.database.RoamRepositoryImpl(db),
            indexingService = indexer
        )
        val appendResult = editor.appendHeadingUnder("target-id", entry)
        assertTrue(
            appendResult is computer.whatthefuck.arcology.editor.EditResult.Success,
            "append should succeed: $appendResult"
        )

        val rows = db.toolsQueries.selectToolRefsByNode(captureId).executeAsList()
        assertEquals(
            listOf("https://child.example.com/source"),
            rows.map { it.ref }
        )
    }

    /**
     * Org block constructs inside leaf bodies. The org-structural lines
     * embedded in this raw string (,* heading stars, ,#+begin_* and
     * ,#+end_* delimiters) are comma-escaped per the repo convention —
     * the tangle tool strips the leading comma on output, and until
     * then they must not read as structure of llm/tools.org itself.
     */
    private val blocksFixtureOrg = """
        :PROPERTIES:
        :ID: blocks_root
        :END:
        #+TITLE: Blocks Fixture

        * quote only note
        :PROPERTIES:
        :ID: quote_only
        :END:

        ,#+begin_quote
        the quoted line survives rendering
        ,#+end_quote

        * mixed blocks note
        :PROPERTIES:
        :ID: mixed_blocks
        :END:

        ,#+begin_quote
        quote payload alpha
        ,#+end_quote

        ,#+begin_src kotlin
        source payload beta
        ,#+end_src

        ,#+begin_example
        example payload gamma
        ,#+end_example

        - plain item delta
          - nested sublist item

          ,#+begin_quote
          eps inside a list
          ,#+end_quote

        The nested list above exercises the parser's single-paragraph
        item limitation (OrgList.kt only parses a leading paragraph per
        item): the nested marker line never becomes a chunk, and the
        indented quote degrades to raw-line paragraphs. Rendered result
        is asserted as parser-fidelity, not reassembly.

        | h1 | h2 |
        |----+----|
        | v1 | v2 |

        * verse and extension blocks note
        :PROPERTIES:
        :ID: verse_ext
        :END:

        ,#+begin_verse
        verse payload zeta
        ,#+end_verse

        ,#+begin_aside
        aside payload eta
        ,#+end_aside

        ,#+begin_edits
        edits payload theta
        ,#+end_edits

        ,#+begin_page_intro
        page intro payload iota
        ,#+end_page_intro

        * html elision note
        :PROPERTIES:
        :ID: html_elision
        :END:

        ,#+begin_html
        raw payload kappa
        ,#+end_html

        ,#+begin_quote
        quote payload lambda
        ,#+end_quote
    """.trimIndent()

    private suspend fun seedBlocksFixture(): Triple<ArcologyDatabase, java.nio.file.Path, java.nio.file.Path> {
        val tmp = kotlin.io.path.createTempDirectory(prefix = "arcology-blocktest-")
        val orgDir = tmp.resolve("org")
        java.nio.file.Files.createDirectories(orgDir)
        orgDir.resolve("blocks.org").writeText(blocksFixtureOrg)

        val db = makeDatabase()
        db.arcologyDatabaseQueries.insertFile("blocks.org", "Blocks Fixture", "h1", 0L, 0L)
        db.arcologyDatabaseQueries.insertNode(
            "blocks_root", "blocks.org", 0, 0, null, null, null, null,
            "Blocks Fixture", "ID: blocks_root", ""
        )
        db.arcologyDatabaseQueries.insertNode(
            "quote_only", "blocks.org", 1, 10, null, null, null, null,
            "quote only note", "ID: quote_only", "Blocks Fixture/quote only note"
        )
        db.arcologyDatabaseQueries.insertNode(
            "mixed_blocks", "blocks.org", 1, 20, null, null, null, null,
            "mixed blocks note", "ID: mixed_blocks", "Blocks Fixture/mixed blocks note"
        )
        db.arcologyDatabaseQueries.insertNode(
            "verse_ext", "blocks.org", 1, 30, null, null, null, null,
            "verse and extension blocks note", "ID: verse_ext", "Blocks Fixture/verse and extension blocks note"
        )
        db.arcologyDatabaseQueries.insertNode(
            "html_elision", "blocks.org", 1, 40, null, null, null, null,
            "html elision note", "ID: html_elision", "Blocks Fixture/html elision note"
        )
        return Triple(db, tmp, orgDir)
    }

    @Test
    fun `quote only leaf body is retrievable`() = runTest {
        val (db, _, orgDir) = seedBlocksFixture()
        val body = loadLeafBody(db, "quote_only", orgDir.toString(), maxBytes = 65536)
        assertNotNull(body, "a quote-only note must yield a body, not the missing/redacted path")
        assertTrue("the quoted line survives rendering" in body, "quote text rendered: $body")
    }

    @Test
    fun `mixed blocks render in leaf body`() = runTest {
        val (db, _, orgDir) = seedBlocksFixture()
        val body = loadLeafBody(db, "mixed_blocks", orgDir.toString(), maxBytes = 65536)!!
        assertTrue("quote payload alpha" in body, "quote chunk: $body")
        assertTrue("source payload beta" in body, "src chunk: $body")
        assertTrue("example payload gamma" in body, "example chunk: $body")
        assertTrue("plain item delta" in body, "list item paragraph: $body")
        // Parser fidelity: the list-item grammar parses only the leading
        // paragraph of an item (OrgList.kt parseListItemChunks TODO), so the
        // nested "- sublist item" line and the indented quote degrade to raw
        // lines. We assert what the parser gives, per the approved decision.
        assertTrue("sublist item" in body, "nested marker line survives as paragraph text: $body")
        assertTrue("eps inside a list" in body, "indented quote content survives: $body")
        assertTrue(Regex("h1\\s*\\|\\s*h2").containsMatchIn(body), "table header cells pipe-joined: $body")
        assertTrue(Regex("v1\\s*\\|\\s*v2").containsMatchIn(body), "table body cells pipe-joined: $body")
        assertFalse("----" in body, "hline is structure, not a rendered row: $body")
    }

    @Test
    fun `verse and extension blocks render in leaf body`() = runTest {
        val (db, _, orgDir) = seedBlocksFixture()
        val body = loadLeafBody(db, "verse_ext", orgDir.toString(), maxBytes = 65536)!!
        assertTrue("verse payload zeta" in body, "verse chunk: $body")
        assertTrue("aside payload eta" in body, "aside chunk: $body")
        assertTrue("edits payload theta" in body, "edits chunk: $body")
        assertTrue("page intro payload iota" in body, "page-intro chunk: $body")
    }

    @Test
    fun `html block elided while sibling quote renders`() = runTest {
        val (db, _, orgDir) = seedBlocksFixture()
        val body = loadLeafBody(db, "html_elision", orgDir.toString(), maxBytes = 65536)!!
        assertFalse("raw payload kappa" in body, "html block content elided: $body")
        assertTrue("quote payload lambda" in body, "sibling quote still renders: $body")
    }
}

TimestampsIndexerPlugin tests: parse org content with OrgFileParser, run plugin.onFileIndexed(result), assert on tool_timestamps through the repository (in-memory DB). Fixture strings comma-escape org-structural lines per repo convention.

kotlin#+name: tools-timestamps-tests:tangle ../src/jvmTest/kotlin/computer/whatthefuck/arcology/llm/TimestampsIndexerPluginTest.kt
package computer.whatthefuck.arcology.llm

import computer.whatthefuck.arcology.database.DatabaseFactory
import computer.whatthefuck.arcology.indexer.IndexerPlugin
import computer.whatthefuck.arcology.parser.OrgFileParser
import computer.whatthefuck.arcology.parser.ParseResult
import kotlinx.coroutines.test.runTest
import kotlin.test.*

class TimestampsIndexerPluginTest {

    private val parser = OrgFileParser()

    /** Parse, harvest into an in-memory db, return the rows for 2026 as records. */
    private fun runPlugin(orgContent: String): List<TimelineStampRecord> {
        val parseResult = parser.parseFileContent(
            "/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200)
        )
        assertTrue(parseResult is ParseResult.Success, "Parse should succeed: $parseResult")
        val db = DatabaseFactory.createInMemoryDatabase()
        val plugin: IndexerPlugin = TimestampsIndexerPlugin(db)
        val repo = ToolQueryRepositoryImpl(db)
        kotlinx.coroutines.runBlocking {
            plugin.onFileIndexed(parseResult as ParseResult.Success)
        }
        return kotlinx.coroutines.runBlocking {
            repo.selectTimestampsBetween("2026-01-01", "2026-12-31")
        }
    }

    @Test
    fun `extracts scheduled and deadline planning stamps`() {
        // NOTE: orgmode-kmp's parsePlanningInfo doesn't allow a line break
        // between planning lines, so SCHEDULED and DEADLINE must share one
        // line (or only the first is planning-typed; the second degrades to
        // an active body stamp — harvested anyway, just kind-labelled
        // differently). Parser fidelity asserted, per repo convention.
        val orgContent = """:PROPERTIES:
:ID:       test-file-id
:END:
,#+TITLE: Test File

,* TODO Task with planning
SCHEDULED: <2026-07-31 Fri 09:00>
:PROPERTIES:
:ID:       task-planning-id
:END:

Task body text.
"""
        val rows = runPlugin(orgContent)
        val scheduled = rows.filter { it.kind == "scheduled" }
        assertEquals(1, scheduled.size, "one scheduled row: $rows")
        assertEquals("2026-07-31", scheduled.first().date)
        assertEquals("task-planning-id", scheduled.first().nodeId)
        // Snippet anchors the planning line the stamp lives on.
        assertTrue(
            "SCHEDULED" in (scheduled.first().snippet ?: ""),
            "snippet anchors the planning line: ${scheduled.first().snippet}"
        )
    }

    @Test
    fun `extracts closed stamp`() {
        val orgContent = """:PROPERTIES:
:ID:       test-file-id
:END:
,#+TITLE: Test File

,* DONE Closed Task
CLOSED: [2026-03-10 Tue 15:30]
:PROPERTIES:
:ID:       closed-task-id
:END:
"""
        val rows = runPlugin(orgContent)
        val closed = rows.filter { it.kind == "closed" }
        assertEquals(1, closed.size, "one closed row: $rows")
        assertEquals("2026-03-10", closed.first().date)
    }

    @Test
    fun `extracts active and inactive body stamps`() {
        val orgContent = """
:PROPERTIES:
:ID:       test-file-id
:END:
,#+TITLE: Test File

,* Meeting notes
:PROPERTIES:
:ID:       meeting-id
:END:
Meeting at <2026-07-31 Fri 14:00> then logged [2026-07-31 Fri 16:20].
"""
        val rows = runPlugin(orgContent)
        val active = rows.filter { it.kind == "active" }
        val inactive = rows.filter { it.kind == "inactive" }
        assertEquals(1, active.size, "active inline stamp extracted: $rows")
        assertEquals(1, inactive.size, "inactive inline stamp extracted: $rows")
        assertEquals("2026-07-31", active.first().date)
        assertEquals("2026-07-31", inactive.first().date)
        assertNotNull(active.first().epoch, "epoch from time-of-day")
        assertTrue(
            "Meeting at" in (active.first().snippet ?: ""),
            "snippet anchors the paragraph line: ${active.first().snippet}"
        )
    }

    @Test
    fun `extracts stamps from id-less headings with olp`() {
        val orgContent = """:PROPERTIES:
:ID:       test-file-id
:END:
,#+TITLE: Test File

,* Therapy notes

,** Session with floating event
Something happened <2026-09-14 Mon 10:00>.
"""
        val rows = runPlugin(orgContent)
        val active = rows.filter { it.kind == "active" }
        assertEquals(1, active.size, "ID-less heading stamp still harvested: $rows")
        assertNull(active.first().nodeId, "no node_id on ID-less heading")
        // Parser convention: file-level title is NOT part of heading OLP.
        assertEquals("Therapy notes/Session with floating event", active.first().olp)
        assertNotNull(active.first().snippet)
    }

    @Test
    fun `extracts logbook state changes`() {
        val orgContent = """:PROPERTIES:
:ID:       test-file-id
:END:
,#+TITLE: Test File

,* TODO Task with logbook
:LOGBOOK:
- State "DONE" from "TODO" [2026-04-07 Tue 08:30]
:END:
:PROPERTIES:
:ID:       logbook-task-id
:END:
"""
        val rows = runPlugin(orgContent)
        val logbook = rows.filter { it.kind == "logbook" }
        assertEquals(1, logbook.size, "logbook state change harvested: $rows")
        assertEquals("2026-04-07", logbook.first().date)
        assertNotNull(logbook.first().epoch)
    }

    @Test
    fun `excluded subtrees drop wholesale`() {
        val orgContent = """:PROPERTIES:
:ID:       test-file-id
:END:
,#+TITLE: Test File

,* Visible heading
:PROPERTIES:
:ID:       visible-id
:END:
See <2026-05-01 Fri 09:00> here.

,* Hidden therapy log :noexport:
Body mention <2026-05-02 Sat 10:00>.

,** Nested inside excluded :ARCHIVE:
Also stamped <2026-05-03 Sun 11:00>.
"""
        val rows = runPlugin(orgContent)
        assertEquals(1, rows.filter { it.kind == "active" }.size, "only visible active stamp: $rows")
        assertNull(rows.firstOrNull { (it.snippet ?: "").contains("Hidden therapy") },
                   "excluded text never present")
        assertNull(rows.firstOrNull { it.olp?.contains("Hidden therapy") == true },
                   "no rows from excluded subtree")
    }

    @Test
    fun `reindex purges stale rows`() {
        val orgContent = """:PROPERTIES:
:ID:       test-file-id
:END:

,* Task
:PROPERTIES:
:ID:       task-r-id
:END:
SCHEDULED: <2026-07-31 Fri 09:00>
"""
        val db = DatabaseFactory.createInMemoryDatabase()
        val plugin: IndexerPlugin = TimestampsIndexerPlugin(db)
        val repo = ToolQueryRepositoryImpl(db)
        val parse1 = parser.parseFileContent(
            "/test.org", orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200)
        ) as ParseResult.Success
        kotlinx.coroutines.runBlocking { plugin.onFileIndexed(parse1) }
        assertTrue(kotlinx.coroutines.runBlocking {
            repo.selectTimestampsBetween("2026-01-01", "2026-12-31")
        }.isNotEmpty())

        // Re-parse without the SCHEDULED line; re-harvest must purge stale rows.
        val orgContent2 = orgContent.replace("SCHEDULED: <2026-07-31 Fri 09:00>\n", "")
        val parse2 = parser.parseFileContent(
            "/test.org", orgContent2, kotlin.time.Instant.fromEpochSeconds(1640995300)
        ) as ParseResult.Success
        kotlinx.coroutines.runBlocking { plugin.onFileIndexed(parse2) }
        assertTrue(kotlinx.coroutines.runBlocking {
            repo.selectTimestampsBetween("2026-01-01", "2026-12-31")
        }.isEmpty(), "stale scheduled row purged on reindex")
    }

    @Test
    fun `snippet spec is line-anchored plus 100 chars each way`() {
        val plugin = TimestampsIndexerPlugin(DatabaseFactory.createInMemoryDatabase())
        val line1 = "x".repeat(60)
        val line2 = "y".repeat(60) + " stamp"
        val line3 = "z".repeat(60)
        val content = "$line1\n$line2\n$line3\n"
        val start2 = line1.length + 1
        val start3 = start2 + line2.length + 1
        val anchor = content.indexOf("stamp")
        // No heading lines in range: pure ±100 extension applies.
        val snip = plugin.snippet(content, listOf(0, start2, start3), anchor, emptyList())
        // Anchor is near the end of line 2: span covers all of line 2 and
        // ~41 chars of line 3 (backward extension stays inside line 1+2).
        assertTrue(snip.contains("stamp"), "anchor text in snippet: $snip")
        assertTrue(snip.contains(line3.take(40)), "extends into the next line: $snip")
        assertTrue(snip.length <= 240, "cap respected: ${snip.length}")
    }

    @Test
    fun `snippet never crosses a heading line`() {
        val plugin = TimestampsIndexerPlugin(DatabaseFactory.createInMemoryDatabase())
        val excludedLine = "* hidden therapy log :noexport: [2026-01-01 Fri]"
        val bodyLine = "note stamp [2026-02-02 Tue] here"
        val content = "$bodyLine\n$excludedLine\ntail\n"
        val anchor = content.indexOf("[2026-02-02")
        val headingAt = content.indexOf("* hidden")
        val snip = plugin.snippet(content, listOf(0), anchor, listOf(headingAt))
        // Forward extension stops at the heading line: excluded title/stamp
        // text must not ride the neighbor's snippet.
        assertFalse(snip.contains("therapy"), "excluded heading not crossed: $snip")
        assertTrue(snip.contains("stamp"), "anchor text present: $snip")
    }

    @Test
    fun `snippet on a heading line does not reach back into the previous body`() {
        // a title stamp's ±100 backward window crossed the section
        // boundary into the previous entry's body.
        val plugin = TimestampsIndexerPlugin(DatabaseFactory.createInMemoryDatabase())
        val prevBody = "The previous heading's text"
        val headingLine = "* [2026-10-01 Thu 08:28] this heading's text"
        val content = "$prevBody\n$headingLine\nforward context continues"
        val headingAt = content.indexOf(headingLine)
        val anchor = content.indexOf("[2026-10-01")
        val snip = plugin.snippet(
            content, computeLineStartsFor(content), anchor, listOf(headingAt)
        )
        assertFalse(snip.contains("previous"), "previous body not reached: $snip")
        assertTrue(snip.contains("this heading"), "heading line present: $snip")
        assertTrue(snip.contains("forward context"), "own-body forward extension stays: $snip")
    }

    /** Line-start mirror of the plugin's private computeLineStarts. */
    private fun computeLineStartsFor(content: String): List<Int> {
        val starts = mutableListOf(0)
        var idx = content.indexOf('\n')
        while (idx != -1) {
            starts.add(idx + 1)
            idx = content.indexOf('\n', idx + 1)
        }
        return starts
    }
}

Harness Wiring

The tools are flat CLI invocations. How each harness gets at them:

Hermes

Hermes has no config.yaml mechanism for declaring CLI-backed tools; its extension surface is MCP servers, Python plugins, and skills. Per its "Skill or Tool?" decision guide, wrapping an external CLI is exactly a skill.

The skill (with per-verb usage, envelope parsing guidance, and required_environment_variables declaring ARCOLOGY_RISK_ENDPOINT / ARCOLOGY_RISK_MODEL so the risk gate env passes through to sandboxed terminal calls) is vendored in the langlemangle microvm module's arcology-notes skill and installed via Hermes' hermesHomeFiles. Update it there.

Environment for the tool-calling user (the skill also prompts for these):

sh:tangle no
export ARCOLOGY_RISK_ENDPOINT=http://windows:11434
export ARCOLOGY_RISK_MODEL=tev1:4b

tev1:4b is the default decision model (classifier.org); nimble:9b and tev1:0.8b are compatible with the same /v1/systemone endpoint per the Ollama model docs and swap in via this env or --risk-model.

opencode: bash permissions + MCP shim

In opencode.json permissions — allow the narrow tool, and the MCP shim (see the langlemangle module's home.file block for the vendored shim) exposes the five verbs as native opencode MCP tools:

json:tangle no
{
  "permission": {
    "bash": {
      "arcology2 notes *": "allow"
    }
  }
}

Deny on =~/org/*= file access plus allow on =arcology2 notes = is the point: the harness gets notes through the narrow tool or not at all.

Related Modules