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:4bdecision model through Ollama's/v1/systemoneendpoint (classifier.org) to judge the risk of sending the file content to remote inference providers.nimble:9bandtev1:0.8bspeak the same endpoint and swap in via--risk-model.Exclusion matches the web interface.
EXCLUDE_TAGShides nodes, and their subheadings here too. An excluded note returnsnot_foundso its existence isn't confirmed.Fail closed. When the risk classifier gates a note,
note-getreturns 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.*
--verboseis a debug instrument.* Invoked per tool, it attaches the classifier's request/response transcript to theriskblock (see classifier.org).
The arcology2 notes Command Group
class NotesCommand : CliktCommand(
name = "notes",
help = "LLM-harness tools: get, search, timeline, capture, sql"
) {
override fun run() = Unit
}Common Base
/**
* 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.
/** 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_htmlare 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.
/**
* 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:
/**
* 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:
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
}arcology2 notes search
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.
class NoteSearchCommand : NoteToolBase(
name = "search",
help = "Search notes by title/content; returns ids, titles and outline paths only"
) {
private val query: String by option("--query", "-q", help = "Search query").required()
private val mode: String by option("--mode", help = "title | content | combined")
.default("combined")
private val limit: Int by option("--limit", help = "Max results").int().default(20)
override fun run() = runBlocking {
if (ensureFresh) runFreshIndexPass(expandTilde(dbPath), expandTilde(orgDir))
val database = DatabaseFactory.createDatabase(expandTilde(dbPath))
val service = SearchService(RoamRepositoryImpl(database))
val results = when (mode) {
"title" -> service.searchPrimary(query, limit)
"content" -> service.searchContent(query, limit = limit)
else -> service.searchCombined(query, primaryLimit = limit, totalLimit = limit)
}
val repo = ToolQueryRepositoryImpl(database)
val filtered = results.filter { r ->
repo.selectAncestorTags(r.node.id).none { it in EXCLUDE_TAG_SET }
}
val data = buildJsonObject {
put("results", JsonArray(filtered.map { r ->
buildJsonObject {
put("id", JsonPrimitive(r.node.id))
r.node.title?.let { put("title", JsonPrimitive(it)) }
put("outline", JsonArray(r.node.outlinePath.map { JsonPrimitive(it) }))
put("score", JsonPrimitive(r.rank))
}
}))
}
printEnvelope(ToolEnvelope(ok = true, data = data))
}
}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:
idis null, context comes fromolp+snippet. There is nonotes geton 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).--kindis a CSV overscheduled,deadline,closed,active,inactive,logbook,journal; *default = all of them, inactive included.journalrows (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.
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-statesCSV or$ARCOLOGY_TODO_STATES, defaulting toNEXT,INPROGRESS,DONE(AppPreferences'DEFAULT_TODO_STATES).--clearremoves 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.
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>';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.
/**
* 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.
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
/**
* 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.
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.
-- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
selectToolJournalNodesBetween:
SELECT id, title, todo, scheduled, deadline
FROM nodes
WHERE file >= ?1 AND file <= ?2
ORDER BY file, pos;Tool JSON Envelope
@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.
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
SCHEDULEDDEADLINECLOSEDfromsection.heading.planningInfo(kindsscheduled,deadline,closed).LOGBOOKdrawerStateChangeentries, kindlogbook;dateis the entry's day in the host's local zone (the logbook parser already reads Emacs-written stamps as local time);epochthe entry second;repeaternull;snippetnull — theState "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;DTStampplus both ends ofDTRange; active and inactive alike (kindsactive/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.
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).
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<<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).
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.
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):
export ARCOLOGY_RISK_ENDPOINT=http://windows:11434
export ARCOLOGY_RISK_MODEL=tev1:4btev1: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:
{
"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
llm/classifier.org — the fail-closed risk classifier gating tool output
app/cli.org — the Clikt command tree this group hangs from
roam/parser.org —
ParseResult.nodeContentsreused for leaf bodiesroam/models.org — the underlying schema (
nodes,node_ancestors,links)roam/editor.org —
OrgDocumentEditorbacksnotes todoandcapture --headingweb/publishing.org — the
EXCLUDE_TAGSpolicy being mirrorededitor/capture-core.org —
CaptureServicereused bynote-captureagenda/indexer.org —
TaskIndexerPluginwalk convention;tool_timestampsis the timeline-wide sibling oftask_timestamps