← Threadline

How Threadline works

FalkorDB is the only database. It holds the company brain (one graph), each visitor's memory (one graph per visitor), the shared blackboard where the Investigator and Verifier agents coordinate, the stored traces that replay runs, the vector index used by both the agent and the baseline, the rate-limit counters, and even the 3D layout. Take FalkorDB out and nothing is left.

Pipeline

  1. Fetch: public GitHub GraphQL for six FalkorDB repositories plus the docs repo, cached as JSONL.
  2. Normalise: deterministic mapping to nodes and edges with stable keys; references parsed from timelines and bodies; concepts from a fixed dictionary.
  3. Embed: text-embedding-3-small at 256 dimensions, cached by content hash (total spend so far about four US cents).
  4. Load: UNWIND batches with MERGE on key, then derived edges in Cypher. About 41 MB of the free tier's 100 MB.
  5. Ask: the Investigator (Groq gpt-oss, or a capped OpenAI gpt-5.4-mini fallback when every Groq model has hit its daily limit) calls typed graph tools; every tool returns rows, node keys, traversed edges and its Cypher, streamed to the browser. The fallback allows 4 calls of at most 2,000 output tokens per run, 10 runs a day and 25 a month across all visitors. At OpenAI's Standard prices ($0.75 input, $0.075 cached input, $4.50 output per million tokens) a typical run costs about $0.0032, a worst-case run about $0.07, and a month at the cap at most about $1.84. Recorded runs never call a model.
  6. Verify: the Verifier checks each cited node was observed in a tool result and is reachable from the question's entry nodes within 4 hops. Rejected claims are handed back once.
  7. Remember: turns, tool calls and facts go to the visitor's own graph; verified tool sequences become shared playbooks.
  8. Stay current: a daily Vercel Cron job merges updated issues and PRs, recomputes derived edges and prunes idle memory graphs.

Graph data model

Label (all brain nodes also carry :Entity)KeyProperties
Reporepo:FalkorDB/falkordb-browsername, language, stars, url
Personperson:<login>login, avatarUrl, isBot, firstSeenAt, lastSeenAt (no names or emails)
Issue / PullRequestissue:FalkorDB/FalkorDB#123number, title, state, createdAt, closedAt, mergedAt, author, summary (600 chars)
File / Directoryfile:FalkorDB/FalkorDB:src/index/x.cpath, ext, depth
Releaserelease:FalkorDB/FalkorDB@v4.8.0tag, publishedAt, url, notes
Labellabel:FalkorDB/FalkorDB:bugname, color
Docdoc:docs:cypher/indexing/vector-indextitle, path, url
Conceptconcept:vector-indexname, aliases (fixed dictionary, no LLM)
Chunkchunk:<owner>:<i>text (600 chars), embedding (vecf32, 256 dims), kind
RelationshipBetween
AUTHORED, REVIEWED {state}, MERGED, COMMENTED_ON {count}Person to Issue or PullRequest
TOUCHED {additions, deletions}PullRequest to File
IN_DIRFile to Directory, Directory to parent Directory
CLOSES {source}PullRequest to Issue (closing references, connected events, fixes #N)
REFERENCES {crossRepo, source}Thread to Thread (timeline cross-references and #N in bodies)
SHIPPED_IN {source}PullRequest to Release (release notes, else first release after merge)
HAS_LABEL, ASSIGNED_TO, IN_REPO, HAS_REPO, PREVIOUSstructure
MENTIONS, DOCUMENTSThread, Doc or Chunk to Concept
PART_OFChunk to Thread or Doc
KNOWS {authored, reviewed, lastAt}derived: Person to Directory
REVIEWS_FOR {count}derived: reviewer to author
CONTRIBUTES_TO / HAS_CONTRIBUTORderived: Person and Repo, both directions for betweenness
CO_CHANGES {count}derived: Directory pairs changed by the same PRs

Memory graphs tl_mem_<visitor>: Session, Turn (episodic), ToolCall, Ref, Fact (semantic). Shared graph tl_shared: Trace, Task, Claim, Agent, Playbook (procedural, with its own vector index), QueryEmbedding, Rate.

Indexes

CREATE INDEX FOR (e:Entity) ON (e.key)
CREATE INDEX FOR (c:Chunk) ON (c.key)
CREATE INDEX FOR (p:Person) ON (p.login)
CREATE INDEX FOR (t:Thread) ON (t.number, t.createdAt, t.state)
CREATE INDEX FOR (d:Directory) ON (d.path)
CREATE INDEX FOR (f:File) ON (f.path)
CALL db.idx.fulltext.createNodeIndex('Entity', 'title', 'name', 'path', 'login', 'tag')
CREATE VECTOR INDEX FOR (c:Chunk) ON (c.embedding) OPTIONS {dimension:256, similarityFunction:'cosine'}

Derived edges

KNOWS (authored)
MATCH (p:Person)-[:AUTHORED]->(pr:PullRequest)-[:TOUCHED]->(:File)-[:IN_DIR*1..6]->(d:Directory)
WHERE p.isBot = false AND pr.mergedAt IS NOT NULL
WITH p, d, count(DISTINCT pr) AS authored, max(pr.mergedAt) AS last
MERGE (p)-[k:KNOWS]->(d)
SET k.authored = authored, k.authoredLastAt = last
KNOWS (reviewed)
MATCH (p:Person)-[rv:REVIEWED]->(pr:PullRequest)-[:TOUCHED]->(:File)-[:IN_DIR*1..6]->(d:Directory)
WHERE p.isBot = false
WITH p, d, count(DISTINCT pr) AS reviewed, max(rv.at) AS last
MERGE (p)-[k:KNOWS]->(d)
SET k.reviewed = reviewed, k.reviewedLastAt = last
KNOWS (lastAt)
MATCH (:Person)-[k:KNOWS]->(:Directory)
SET k.authored = coalesce(k.authored, 0), k.reviewed = coalesce(k.reviewed, 0),
    k.lastAt = CASE WHEN coalesce(k.authoredLastAt, 0) >= coalesce(k.reviewedLastAt, 0) THEN k.authoredLastAt ELSE k.reviewedLastAt END
REVIEWS_FOR
MATCH (r:Person)-[rv:REVIEWED]->(pr:PullRequest)<-[:AUTHORED]-(a:Person)
WHERE r.isBot = false AND a.isBot = false AND id(r) <> id(a)
WITH r, a, count(rv) AS n
MERGE (r)-[e:REVIEWS_FOR]->(a)
SET e.count = n
CONTRIBUTES_TO (authored)
MATCH (p:Person)-[:AUTHORED]->(t:Thread)-[:IN_REPO]->(r:Repo)
WHERE p.isBot = false
WITH p, r, sum(CASE WHEN t:PullRequest THEN 1 ELSE 0 END) AS prs, sum(CASE WHEN t:Issue THEN 1 ELSE 0 END) AS issues
MERGE (p)-[c:CONTRIBUTES_TO]->(r)
SET c.prs = prs, c.issues = issues
CONTRIBUTES_TO (reviews)
MATCH (p:Person)-[rv:REVIEWED]->(:PullRequest)-[:IN_REPO]->(r:Repo)
WHERE p.isBot = false
WITH p, r, count(rv) AS n
MERGE (p)-[c:CONTRIBUTES_TO]->(r)
SET c.reviews = n, c.prs = coalesce(c.prs, 0), c.issues = coalesce(c.issues, 0)
HAS_CONTRIBUTOR
MATCH (p:Person)-[c:CONTRIBUTES_TO]->(r:Repo)
WHERE coalesce(c.prs, 0) + coalesce(c.reviews, 0) >= 3
MERGE (r)-[h:HAS_CONTRIBUTOR]->(p)
SET h.weight = coalesce(c.prs, 0) + coalesce(c.reviews, 0)
CO_CHANGES
MATCH (pr:PullRequest)-[:TOUCHED]->(:File)-[:IN_DIR]->(d:Directory)
WITH pr, collect(DISTINCT d) AS ds
WHERE size(ds) > 1 AND size(ds) <= 25
UNWIND ds AS d1
UNWIND ds AS d2
WITH d1, d2, pr WHERE id(d1) < id(d2)
WITH d1, d2, count(DISTINCT pr) AS n WHERE n >= 3
MERGE (d1)-[c:CO_CHANGES]->(d2)
SET c.count = n

Cypher catalogue (the queries the tools run)

Rendered from the same constants the server executes. All values are parameters.

vectorEntryused by find_entities
CALL db.idx.vector.queryNodes('Chunk', 'embedding', $k, vecf32($vec)) YIELD node, score
MATCH (node)-[:PART_OF]->(n:Entity)
RETURN n.key AS key, labels(n) AS labels, coalesce(n.title, n.name, n.tag, n.login, n.path, n.key) AS name, n.repo AS repo, 1 - min(score) AS similarity
ORDER BY similarity DESC LIMIT $limit
fulltextEntryused by find_entities
CALL db.idx.fulltext.queryNodes('Entity', $text) YIELD node, score
WITH node AS n, score WHERE NOT n:Label
RETURN n.key AS key, labels(n) AS labels, coalesce(n.title, n.name, n.tag, n.login, n.path, n.key) AS name, n.repo AS repo, score
ORDER BY score DESC LIMIT $limit
byKeysused by find_entities
MATCH (n:Entity) WHERE n.key IN $keys
RETURN n.key AS key, labels(n) AS labels, coalesce(n.title, n.name, n.tag, n.login, n.path, n.key) AS name, n.repo AS repo
threadByNumberused by find_entities
MATCH (n:Thread {number: $number})-[:IN_REPO]->(r:Repo)
WHERE $repo IS NULL OR toLower(r.name) = toLower($repo)
RETURN n.key AS key, labels(n) AS labels, n.title AS name, r.fullName AS repo, n.state AS state
conceptMatchused by find_entities, who_knows
MATCH (n:Concept)
WHERE toLower(n.name) CONTAINS toLower($text) OR toLower(n.aliases) CONTAINS toLower($text)
RETURN n.key AS key, labels(n) AS labels, n.name AS name, null AS repo LIMIT 3
detailsused by details
MATCH (n:Entity {key: $key})
OPTIONAL MATCH (n)-[r]-(m:Entity)
WITH n, type(r) AS rel, count(m) AS c
RETURN n.key AS key, labels(n) AS labels, properties(n) AS props, collect({rel: rel, count: c}) AS degree
expandused by expand
MATCH (s:Entity {key: $key})-[r]-(n:Entity)
WHERE (size($types) = 0 OR type(r) IN $types) AND coalesce(n.isBot, false) = false
RETURN type(r) AS rel, id(startNode(r)) = id(s) AS outgoing, n.key AS key, labels(n) AS labels, coalesce(n.title, n.name, n.tag, n.login, n.path, n.key) AS name,
       coalesce(n.mergedAt, n.createdAt, n.publishedAt, n.lastSeenAt, 0) AS at
ORDER BY at DESC LIMIT $limit
whoKnowsAreaused by who_knows
MATCH (d:Directory)
WHERE d.depth > 0 AND NOT d.path STARTS WITH '.' AND toLower(d.path) CONTAINS toLower($term) AND ($repo IS NULL OR toLower(d.repo) ENDS WITH toLower($repo))
WITH d ORDER BY d.depth ASC LIMIT 4
MATCH (p:Person)-[k:KNOWS]->(d)
WITH p, d, k, (2 * k.authored + k.reviewed) AS weight, ($now - k.lastAt) / 86400.0 AS ageDays
RETURN p.key AS key, p.login AS person, d.key AS area, d.path AS path, k.authored AS authored, k.reviewed AS reviewed,
       toInteger(ageDays) AS daysSinceLast, weight * exp(-ageDays / 180.0) AS score
ORDER BY score DESC LIMIT 10
whoKnowsConceptused by who_knows
MATCH (c:Concept {key: $concept})<-[:MENTIONS]-(pr:PullRequest)
WHERE pr.mergedAt IS NOT NULL AND ($repo IS NULL OR toLower(pr.repo) ENDS WITH toLower($repo))
MATCH (p:Person)-[r:AUTHORED|REVIEWED]->(pr)
WHERE p.isBot = false
WITH p, c, count(DISTINCT pr) AS prs, sum(CASE WHEN type(r) = 'AUTHORED' THEN 2 ELSE 1 END) AS weight,
     max(pr.mergedAt) AS last, collect(DISTINCT pr.key)[0..3] AS sample
RETURN p.key AS key, p.login AS person, c.key AS area, prs, toInteger(($now - last) / 86400) AS daysSinceLast,
       weight * exp(-(($now - last) / 86400.0) / 180.0) AS score, sample
ORDER BY score DESC LIMIT 10
declaredOwnersused by who_knows
MATCH (p:Person)-[o:DECLARED_OWNER]->(x:Entity) WHERE x.key IN $areas
RETURN p.key AS key, p.login AS person, x.key AS area, o.pattern AS pattern
traceIssueused by trace
MATCH (i:Issue {key: $key})
OPTIONAL MATCH (fix:PullRequest)-[c:CLOSES]->(i)
OPTIONAL MATCH (fix)-[:SHIPPED_IN]->(rel:Release)
OPTIONAL MATCH (author:Person)-[:AUTHORED]->(fix)
OPTIONAL MATCH (opener:Person)-[:AUTHORED]->(i)
RETURN i.key AS issue, i.title AS title, i.state AS state, opener.login AS openedBy,
       fix.key AS fix, fix.title AS fixTitle, fix.mergedAt AS mergedAt, c.source AS linkSource,
       author.login AS fixAuthor, rel.key AS release, rel.tag AS tag, rel.publishedAt AS releasedAt
tracePrused by trace
MATCH (pr:PullRequest {key: $key})
OPTIONAL MATCH (pr)-[c:CLOSES]->(i:Issue)
OPTIONAL MATCH (pr)-[s:SHIPPED_IN]->(rel:Release)
OPTIONAL MATCH (author:Person)-[:AUTHORED]->(pr)
OPTIONAL MATCH (rv:Person)-[r:REVIEWED]->(pr)
RETURN pr.key AS pr, pr.title AS title, pr.state AS state, pr.mergedAt AS mergedAt, author.login AS author,
       collect(DISTINCT i.key) AS closes, collect(DISTINCT rel.key) AS releases, collect(DISTINCT rel.tag) AS tags,
       collect(DISTINCT s.source) AS shipSource, collect(DISTINCT {login: rv.login, state: r.state}) AS reviewers
referencesused by trace
MATCH (t:Thread {key: $key})-[r:REFERENCES|CLOSES]-(o:Thread)
RETURN type(r) AS rel, id(startNode(r)) = id(t) AS outgoing, o.key AS key, o.title AS name, o.repo AS repo, o.state AS state,
       coalesce(r.crossRepo, o.repo <> t.repo) AS crossRepo
ORDER BY crossRepo DESC LIMIT 25
connectused by connect
MATCH (a:Entity {key: $a}), (b:Entity {key: $b})
WITH a, b
MATCH p = allShortestPaths((a)-[:REFERENCES|CLOSES|AUTHORED|REVIEWED|TOUCHED|SHIPPED_IN|COMMENTED_ON|MENTIONS*..6]-(b))
RETURN [n IN nodes(p) | n.key] AS keys, [r IN relationships(p) | type(r)] AS rels
LIMIT 3
impactOpenPrsused by impact
MATCH (d:Directory)
WHERE d.depth > 0 AND NOT d.path STARTS WITH '.' AND toLower(d.path) CONTAINS toLower($term) AND ($repo IS NULL OR toLower(d.repo) ENDS WITH toLower($repo))
WITH d ORDER BY d.depth ASC LIMIT 3
MATCH (d)<-[:IN_DIR*1..6]-(f:File)<-[t:TOUCHED]-(pr:PullRequest)
WHERE pr.state = 'OPEN'
RETURN pr.key AS key, pr.title AS name, d.key AS areaKey, d.path AS area, count(DISTINCT f) AS files, pr.createdAt AS createdAt
ORDER BY files DESC LIMIT 15
impactOpenIssuesused by impact
MATCH (d:Directory)
WHERE d.depth > 0 AND NOT d.path STARTS WITH '.' AND toLower(d.path) CONTAINS toLower($term) AND ($repo IS NULL OR toLower(d.repo) ENDS WITH toLower($repo))
WITH d ORDER BY d.depth ASC LIMIT 3
MATCH (d)<-[:IN_DIR*1..6]-(:File)<-[:TOUCHED]-(pr:PullRequest)-[:CLOSES|REFERENCES]-(i:Issue)
WHERE i.state = 'OPEN'
RETURN i.key AS key, i.title AS name, d.key AS areaKey, d.path AS area, count(DISTINCT pr) AS viaPrs
ORDER BY viaPrs DESC LIMIT 10
coChangesused by impact
MATCH (d:Directory)
WHERE d.depth > 0 AND NOT d.path STARTS WITH '.' AND toLower(d.path) CONTAINS toLower($term) AND ($repo IS NULL OR toLower(d.repo) ENDS WITH toLower($repo))
WITH d ORDER BY d.depth ASC LIMIT 3
MATCH (d)-[c:CO_CHANGES]-(o:Directory)
RETURN o.key AS key, o.path AS name, d.key AS areaKey, o.repo AS repo, c.count AS together
ORDER BY together DESC LIMIT 8
activity.AUTHOREDused by activity
MATCH (p:Person)-[e:AUTHORED]->(t:Thread)-[:IN_REPO]->(r:Repo)
WHERE p.isBot = false AND ($repo IS NULL OR toLower(r.name) = toLower($repo)) AND t.createdAt >= $since AND t.createdAt < $until
  AND ($kind IS NULL OR ($kind = 'pr' AND t:PullRequest) OR ($kind = 'issue' AND t:Issue))
RETURN p.key AS key, p.login AS person, count(DISTINCT t) AS n, collect(t.key)[0..3] AS sample
ORDER BY n DESC LIMIT $limit
activity.REVIEWEDused by activity
MATCH (p:Person)-[e:REVIEWED]->(t:PullRequest)-[:IN_REPO]->(r:Repo)
WHERE p.isBot = false AND ($repo IS NULL OR toLower(r.name) = toLower($repo)) AND t.createdAt >= $since AND t.createdAt < $until
RETURN p.key AS key, p.login AS person, count(DISTINCT t) AS n, collect(t.key)[0..3] AS sample
ORDER BY n DESC LIMIT $limit
activity.MERGEDused by activity
MATCH (p:Person)-[e:MERGED]->(t:PullRequest)-[:IN_REPO]->(r:Repo)
WHERE p.isBot = false AND ($repo IS NULL OR toLower(r.name) = toLower($repo)) AND t.mergedAt >= $since AND t.mergedAt < $until
RETURN p.key AS key, p.login AS person, count(DISTINCT t) AS n, collect(t.key)[0..3] AS sample
ORDER BY n DESC LIMIT $limit
activity.COMMENTED_ONused by activity
MATCH (p:Person)-[e:COMMENTED_ON]->(t:Thread)-[:IN_REPO]->(r:Repo)
WHERE p.isBot = false AND ($repo IS NULL OR toLower(r.name) = toLower($repo)) AND t.createdAt >= $since AND t.createdAt < $until
RETURN p.key AS key, p.login AS person, sum(e.count) AS n, collect(t.key)[0..3] AS sample
ORDER BY n DESC LIMIT $limit
activityInAreaused by activity
MATCH (d:Directory)
WHERE toLower(d.path) = toLower($area) AND ($repo IS NULL OR toLower(d.repo) ENDS WITH toLower($repo))
MATCH (d)<-[:IN_DIR*1..6]-(:File)<-[:TOUCHED]-(t:PullRequest)<-[e:AUTHORED|REVIEWED]-(p:Person)
WHERE p.isBot = false AND t.mergedAt IS NOT NULL AND type(e) = $rel AND t.createdAt >= $since AND t.createdAt < $until
RETURN p.key AS key, p.login AS person, d.key AS area, count(DISTINCT t) AS n, collect(DISTINCT t.key)[0..3] AS sample
ORDER BY n DESC LIMIT $limit
pageRankReviewersused by rank (reviewers)
CALL algo.pageRank('Person', 'REVIEWS_FOR') YIELD node, score
WHERE node.isBot = false
RETURN node.key AS key, node.login AS person, score
ORDER BY score DESC LIMIT 10
betweennessBridgesused by rank (bridges)
CALL algo.betweenness({nodeLabels: ['Person', 'Repo'], relationshipTypes: ['CONTRIBUTES_TO', 'HAS_CONTRIBUTOR']}) YIELD node, score
WITH node, score WHERE node:Person
MATCH (node)-[c:CONTRIBUTES_TO]->(r:Repo)
RETURN node.key AS key, node.login AS person, score, collect(r.name) AS repos
ORDER BY score DESC LIMIT 10
subsystemsused by rank (subsystems)
CALL algo.labelPropagation({nodeLabels: ['Directory'], relationshipTypes: ['CO_CHANGES']}) YIELD node, communityId
WITH communityId, collect(node) AS dirs
WHERE size(dirs) >= 2
RETURN communityId, [d IN dirs | d.key][0..8] AS keys, [d IN dirs | d.repo + ':' + d.path][0..8] AS paths, size(dirs) AS size
ORDER BY size DESC LIMIT 8
verifyPathused by verifier
MATCH (a:Entity {key: $entry}), (b:Entity {key: $cited})
WITH a, b
MATCH p = allShortestPaths((a)-[:AUTHORED|REVIEWED|MERGED|COMMENTED_ON|ASSIGNED_TO|TOUCHED|IN_DIR|CLOSES|REFERENCES|SHIPPED_IN|MENTIONS|DOCUMENTS|KNOWS|REVIEWS_FOR|CO_CHANGES|DECLARED_OWNER*..4]-(b))
RETURN length(p) AS hops, [n IN nodes(p) | n.key] AS keys, [r IN relationships(p) | type(r)] AS rels
LIMIT 1
neighbourhoodused by viewer
MATCH (s:Entity {key: $key})-[r1]-(n1:Entity)
WITH s, r1, n1 LIMIT 40
OPTIONAL MATCH (n1)-[r2]-(n2:Entity) WHERE n2 <> s
WITH s, r1, n1, collect({r: type(r2), k: n2.key, out: id(startNode(r2)) = id(n1)})[0..4] AS hop2
RETURN n1.key AS key, type(r1) AS rel, id(startNode(r1)) = id(s) AS outgoing, hop2
triageIssueused by draft_triage
MATCH (i:Issue {key: $key})
OPTIONAL MATCH (i)-[:HAS_LABEL]->(l:Label)
OPTIONAL MATCH (c:Chunk)-[:PART_OF]->(i)
WITH i, collect(DISTINCT l.name) AS labels, collect(c)[0] AS c
RETURN i.key AS key, i.title AS title, i.state AS state, i.summary AS summary, labels, c.embedding AS e
triageSimilarused by draft_triage
CALL db.idx.vector.queryNodes('Chunk', 'embedding', 24, vecf32($vec)) YIELD node, score
MATCH (node)-[:PART_OF]->(t:Thread)
WHERE t.key <> $key
RETURN t.key AS key, t.title AS name, t.state AS state, 1 - min(score) AS similarity
ORDER BY similarity DESC LIMIT 6
triageOwnersused by draft_triage
MATCH (t:Thread) WHERE t.key IN $similar
OPTIONAL MATCH (fix:PullRequest)-[:CLOSES]->(t)
WITH CASE WHEN fix IS NOT NULL THEN fix WHEN t:PullRequest THEN t ELSE null END AS p
WITH p WHERE p IS NOT NULL
MATCH (p)-[:TOUCHED]->(:File)-[:IN_DIR]->(d:Directory)
WITH d, count(DISTINCT p) AS n ORDER BY n DESC LIMIT 3
MATCH (person:Person)-[k:KNOWS]->(d)
WHERE person.isBot = false
RETURN person.key AS key, person.login AS person, d.key AS area, d.path AS path, k.authored AS authored, k.reviewed AS reviewed
ORDER BY k.authored * 2 + k.reviewed DESC LIMIT 6
triageLabelsused by draft_triage
MATCH (t:Thread)-[:HAS_LABEL]->(l:Label) WHERE t.key IN $similar
RETURN l.name AS label, count(t) AS n ORDER BY n DESC LIMIT 4
vectorRagused by vector baseline
CALL db.idx.vector.queryNodes('Chunk', 'embedding', $k, vecf32($vec)) YIELD node, score
MATCH (node)-[:PART_OF]->(n:Entity)
RETURN node.key AS chunk, node.text AS text, n.key AS key, coalesce(n.title, n.name, n.tag, n.login, n.path, n.key) AS name, n.author AS author, n.state AS state, n.createdAt AS createdAt, 1 - score AS score
ORDER BY score DESC

Memory and coordination Cypher

writeTurn
MERGE (s:Session {id: $sid})
ON CREATE SET s.startedAt = $now
CREATE (t:Turn {id: $tid, question: $q, answer: $a, at: $now, verified: $verified, total: $total, mode: $mode, traceId: $traceId})
MERGE (s)-[:HAS_TURN]->(t)
WITH s, t
OPTIONAL MATCH (s)-[:HAS_TURN]->(prev:Turn) WHERE prev.id <> t.id
WITH t, prev ORDER BY prev.at DESC LIMIT 1
FOREACH (_ IN CASE WHEN prev IS NULL THEN [] ELSE [1] END | MERGE (prev)-[:NEXT]->(t))
WITH t
UNWIND $calls AS c
CREATE (tc:ToolCall {tool: c.tool, step: c.step, ms: c.ms, rows: c.rows, cypher: c.cypher})
MERGE (t)-[:RAN]->(tc)
FOREACH (k IN c.refs | MERGE (r:Ref {key: k}) MERGE (tc)-[:TOUCHED_REF]->(r))
writeFact
MATCH (t:Turn {id: $tid})
MERGE (f:Fact {text: $text})
ON CREATE SET f.kind = $kind, f.at = $now, f.confidence = 1.0
MERGE (f)-[:LEARNED_FROM]->(t)
WITH f
UNWIND $refs AS k
MERGE (r:Ref {key: k})
MERGE (f)-[:ABOUT]->(r)
readFacts
MATCH (f:Fact) RETURN f.text AS text, f.kind AS kind, f.at AS at ORDER BY f.at DESC LIMIT 12
previousTurn
MATCH (:Session {id: $sid})-[:HAS_TURN]->(t:Turn)
OPTIONAL MATCH (t)-[:RAN]->(:ToolCall)-[:TOUCHED_REF]->(r:Ref)
WITH t, collect(DISTINCT r.key)[0..10] AS refs
RETURN t.question AS question, t.at AS at, refs ORDER BY at DESC LIMIT 1
history
MATCH (s:Session)-[:HAS_TURN]->(t:Turn)
OPTIONAL MATCH (t)-[:RAN]->(tc:ToolCall)
WITH s, t, count(tc) AS calls
RETURN s.id AS session, t.id AS id, t.question AS question, t.at AS at, t.verified AS verified, t.total AS total, t.traceId AS traceId, calls
ORDER BY at DESC LIMIT 20
recallPlaybook
CALL db.idx.vector.queryNodes('Playbook', 'embedding', 3, vecf32($vec)) YIELD node, score
WITH node, 1 - score AS similarity WHERE similarity > 0.86 AND node.successes >= 1
RETURN node.questionType AS questionType, node.toolSequence AS toolSequence, node.successes AS successes, similarity
ORDER BY similarity DESC LIMIT 1
writePlaybook
MERGE (p:Playbook {questionType: $qt, toolSequence: $seq})
ON CREATE SET p.uses = 0, p.successes = 0, p.embedding = vecf32($vec), p.example = $q, p.createdAt = $now
SET p.uses = p.uses + 1, p.successes = p.successes + $ok, p.lastAt = $now
writeCoordination
MERGE (inv:Agent {name: 'investigator'})
MERGE (ver:Agent {name: 'verifier'})
CREATE (task:Task {id: $traceId, question: $q, status: $status, at: $now})
CREATE (inv)-[:WORKED_ON]->(task)
WITH inv, ver, task
UNWIND $claims AS c
CREATE (cl:Claim {key: c.key, ok: c.ok, hops: c.hops, observed: c.observed})
CREATE (inv)-[:POSTED]->(cl)-[:FOR]->(task)
CREATE (cl)-[:CHECKED_BY {verdict: CASE WHEN c.ok THEN 'verified' ELSE 'rejected' END, pathLength: c.hops}]->(ver)
writeHandoff
MATCH (task:Task {id: $traceId})
CREATE (task)-[:HANDED_OFF {from: 'verifier', to: 'investigator', reason: $reason, at: $now}]->(task)
saveTrace
CREATE (t:Trace {id: $id, question: $q, mode: $mode, at: $now, showcase: $showcase, verified: $verified, total: $total, json: $json})
getTrace
MATCH (t:Trace {id: $id}) RETURN t.json AS json
listShowcase
MATCH (t:Trace) WHERE t.showcase = true RETURN t.id AS id, t.question AS question, t.mode AS mode, t.verified AS verified, t.total AS total, t.at AS at ORDER BY t.id ASC

MCP

The same typed tools are served over MCP (Streamable HTTP) at /api/mcp: find_entities, details, expand, who_knows, trace, connect, impact, activity, rank, draft_triage and cypher_readonly. The client's own model chooses the tools; the server never calls an LLM.