From 7fa76053435d9c8ea50dd11619c160411d028d94 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 29 Sep 2026 07:34:39 +0000 Subject: [PATCH] Sync 360-degree story research graph --- src/db.ts | 507 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 165 ++++++++++++++++- src/mcp.ts | 125 ++++++++++++- 3 files changed, 793 insertions(+), 4 deletions(-) diff --git a/src/db.ts b/src/db.ts index 36b3c45..e528195 100644 --- a/src/db.ts +++ b/src/db.ts @@ -88,6 +88,74 @@ export async function migrate() { created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + + CREATE TABLE IF NOT EXISTS story_threads ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + question TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'researching', + created_by_sub TEXT NOT NULL, + created_by_name TEXT NOT NULL, + created_by_client_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_story_threads_status_updated + ON story_threads(status, updated_at DESC); + + CREATE TABLE IF NOT EXISTS story_nodes ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES story_threads(id) ON DELETE CASCADE, + node_type TEXT NOT NULL, + title TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + source_id TEXT REFERENCES sources(id) ON DELETE SET NULL, + signal_id TEXT REFERENCES signals(id) ON DELETE SET NULL, + evidence_id TEXT REFERENCES evidence(id) ON DELETE SET NULL, + opportunity_id TEXT REFERENCES opportunities(id) ON DELETE SET NULL, + message_id TEXT, + confidence NUMERIC(4,3), + created_by_sub TEXT NOT NULL, + created_by_name TEXT NOT NULL, + created_by_client_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_story_nodes_thread + ON story_nodes(thread_id, created_at ASC); + + CREATE TABLE IF NOT EXISTS story_edges ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES story_threads(id) ON DELETE CASCADE, + from_node_id TEXT NOT NULL REFERENCES story_nodes(id) ON DELETE CASCADE, + to_node_id TEXT NOT NULL REFERENCES story_nodes(id) ON DELETE CASCADE, + relationship TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + created_by_sub TEXT NOT NULL, + created_by_name TEXT NOT NULL, + created_by_client_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(thread_id, from_node_id, to_node_id, relationship) + ); + + CREATE INDEX IF NOT EXISTS idx_story_edges_thread + ON story_edges(thread_id, created_at ASC); + + CREATE TABLE IF NOT EXISTS story_research_checks ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES story_threads(id) ON DELETE CASCADE, + check_type TEXT NOT NULL DEFAULT 'counter_evidence', + result TEXT NOT NULL, + sources_checked JSONB NOT NULL DEFAULT '[]'::jsonb, + summary TEXT NOT NULL DEFAULT '', + completed_by_sub TEXT NOT NULL, + completed_by_name TEXT NOT NULL, + completed_by_client_id TEXT NOT NULL, + completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(thread_id, check_type) + ); + CREATE TABLE IF NOT EXISTS agent_messages ( id TEXT PRIMARY KEY, actor_sub TEXT NOT NULL, @@ -396,6 +464,445 @@ export async function searchEvidence(query: string, limit = 20) { return result.rows; } + +export interface StoryActor { + sub: string; + name: string; + clientId: string; +} + +const STORY_NODE_TYPES = new Set([ + "signal", "evidence", "claim", "counterclaim", "trend", "forecast", + "actual", "question", "context", "organisation", "person", "place", "metric" +]); + +const STORY_RELATIONSHIPS = new Set([ + "supports", "contradicts", "qualifies", "explains", "correlates_with", + "precedes", "updates", "supersedes", "same_subject", + "regional_breakdown_of", "forecast_for", "actual_for", "investigates" +]); + +const STORY_STATUSES = new Set(["researching", "review", "develop", "closed"]); + +export async function createStoryThread( + args: { title: string; question?: string }, + actor: StoryActor +) { + const title = String(args.title ?? "").trim(); + const question = String(args.question ?? "").trim(); + if (!title) throw new Error("Story thread title is required"); + if (title.length > 220) throw new Error("Story thread title is too long"); + if (question.length > 1200) throw new Error("Story thread question is too long"); + + const id = randomUUID(); + const result = await pool.query( + `INSERT INTO story_threads( + id,title,question,status,created_by_sub,created_by_name,created_by_client_id + ) VALUES($1,$2,$3,'researching',$4,$5,$6) + RETURNING *`, + [id, title, question, actor.sub, actor.name, actor.clientId] + ); + + await pool.query( + `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) + VALUES($1,'story_thread.created','story_thread',$2,$3::jsonb)`, + [randomUUID(), id, JSON.stringify({ actor, title, question })] + ); + + return result.rows[0]; +} + +export async function listStoryThreads(args: { + status?: string; + limit?: number; +} = {}) { + const values: unknown[] = []; + const where: string[] = []; + + if (args.status) { + if (!STORY_STATUSES.has(args.status)) throw new Error("Invalid story status"); + values.push(args.status); + where.push(`t.status=${values.length}`); + } + + const limit = Math.min(Math.max(Number(args.limit ?? 30), 1), 100); + values.push(limit); + + const result = await pool.query( + `SELECT t.*, + (SELECT COUNT(*)::int FROM story_nodes n WHERE n.thread_id=t.id) AS node_count, + (SELECT COUNT(*)::int FROM story_edges e WHERE e.thread_id=t.id) AS edge_count, + (SELECT result FROM story_research_checks c + WHERE c.thread_id=t.id AND c.check_type='counter_evidence' + LIMIT 1) AS counter_evidence_result + FROM story_threads t + ${where.length ? "WHERE " + where.join(" AND ") : ""} + ORDER BY t.updated_at DESC + LIMIT ${values.length}`, + values + ); + return result.rows; +} + +export async function getStoryThread(id: string) { + const threadResult = await pool.query( + `SELECT t.*, + (SELECT result FROM story_research_checks c + WHERE c.thread_id=t.id AND c.check_type='counter_evidence' + LIMIT 1) AS counter_evidence_result + FROM story_threads t + WHERE t.id=$1`, + [id] + ); + if (!threadResult.rowCount) return null; + + const [nodes, edges, checks] = await Promise.all([ + pool.query( + `SELECT n.*, + COALESCE(n.source_id,s.source_id) AS provenance_source_id, + e.source_url AS evidence_url, + s.url AS signal_url, + m.title AS message_title + FROM story_nodes n + LEFT JOIN evidence e ON e.id=n.evidence_id + LEFT JOIN signals s ON s.id=COALESCE(n.signal_id,e.signal_id) + LEFT JOIN agent_messages m ON m.id=n.message_id + WHERE n.thread_id=$1 + ORDER BY n.created_at ASC`, + [id] + ), + pool.query( + "SELECT * FROM story_edges WHERE thread_id=$1 ORDER BY created_at ASC", + [id] + ), + pool.query( + "SELECT * FROM story_research_checks WHERE thread_id=$1 ORDER BY completed_at DESC", + [id] + ) + ]); + + return { + ...threadResult.rows[0], + nodes: nodes.rows, + edges: edges.rows, + research_checks: checks.rows + }; +} + +export async function addStoryNode( + threadId: string, + args: { + nodeType: string; + title: string; + summary?: string; + sourceId?: string | null; + signalId?: string | null; + evidenceId?: string | null; + opportunityId?: string | null; + messageId?: string | null; + confidence?: number | null; + }, + actor: StoryActor +) { + const nodeType = String(args.nodeType ?? ""); + if (!STORY_NODE_TYPES.has(nodeType)) throw new Error("Invalid story node type"); + + const title = String(args.title ?? "").trim(); + const summary = String(args.summary ?? "").trim(); + if (!title) throw new Error("Story node title is required"); + if (title.length > 240) throw new Error("Story node title is too long"); + if (summary.length > 8000) throw new Error("Story node summary is too long"); + + const confidence = + args.confidence == null ? null : Number(args.confidence); + if (confidence != null && (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)) { + throw new Error("confidence must be between 0 and 1"); + } + + const thread = await pool.query("SELECT id FROM story_threads WHERE id=$1", [threadId]); + if (!thread.rowCount) throw new Error("Story thread not found"); + + const id = randomUUID(); + const result = await pool.query( + `INSERT INTO story_nodes( + id,thread_id,node_type,title,summary,source_id,signal_id,evidence_id, + opportunity_id,message_id,confidence, + created_by_sub,created_by_name,created_by_client_id + ) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) + RETURNING *`, + [ + id, threadId, nodeType, title, summary, + args.sourceId ?? null, + args.signalId ?? null, + args.evidenceId ?? null, + args.opportunityId ?? null, + args.messageId ?? null, + confidence, + actor.sub, actor.name, actor.clientId + ] + ); + + await pool.query("UPDATE story_threads SET updated_at=NOW() WHERE id=$1", [threadId]); + await pool.query( + `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) + VALUES($1,'story_node.added','story_node',$2,$3::jsonb)`, + [randomUUID(), id, JSON.stringify({ threadId, nodeType, actor })] + ); + + return result.rows[0]; +} + +export async function addStoryEdge( + threadId: string, + args: { + fromNodeId: string; + toNodeId: string; + relationship: string; + reason?: string; + }, + actor: StoryActor +) { + const relationship = String(args.relationship ?? ""); + if (!STORY_RELATIONSHIPS.has(relationship)) throw new Error("Invalid story relationship"); + if (args.fromNodeId === args.toNodeId) throw new Error("Story edge cannot point to itself"); + + const nodeCheck = await pool.query( + `SELECT id FROM story_nodes + WHERE thread_id=$1 AND id = ANY($2::text[])`, + [threadId, [args.fromNodeId, args.toNodeId]] + ); + if (nodeCheck.rowCount !== 2) { + throw new Error("Both story nodes must belong to this thread"); + } + + const id = randomUUID(); + const result = await pool.query( + `INSERT INTO story_edges( + id,thread_id,from_node_id,to_node_id,relationship,reason, + created_by_sub,created_by_name,created_by_client_id + ) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) + ON CONFLICT(thread_id,from_node_id,to_node_id,relationship) + DO UPDATE SET reason=EXCLUDED.reason + RETURNING *`, + [ + id, threadId, args.fromNodeId, args.toNodeId, relationship, + String(args.reason ?? "").slice(0, 2000), + actor.sub, actor.name, actor.clientId + ] + ); + + await pool.query("UPDATE story_threads SET updated_at=NOW() WHERE id=$1", [threadId]); + return result.rows[0]; +} + +export async function completeCounterEvidenceCheck( + threadId: string, + args: { result: string; sourcesChecked: string[]; summary: string }, + actor: StoryActor +) { + const resultValue = String(args.result ?? ""); + if (!new Set(["found", "none_found"]).has(resultValue)) { + throw new Error("Counter-evidence result must be found or none_found"); + } + + const sources = Array.from( + new Set((args.sourcesChecked ?? []).map((v) => String(v).trim()).filter(Boolean)) + ).slice(0, 50); + if (!sources.length) throw new Error("At least one checked source is required"); + + const summary = String(args.summary ?? "").trim(); + if (!summary) throw new Error("Counter-evidence summary is required"); + + const id = randomUUID(); + const result = await pool.query( + `INSERT INTO story_research_checks( + id,thread_id,check_type,result,sources_checked,summary, + completed_by_sub,completed_by_name,completed_by_client_id + ) + VALUES($1,$2,'counter_evidence',$3,$4::jsonb,$5,$6,$7,$8) + ON CONFLICT(thread_id,check_type) + DO UPDATE SET + result=EXCLUDED.result, + sources_checked=EXCLUDED.sources_checked, + summary=EXCLUDED.summary, + completed_by_sub=EXCLUDED.completed_by_sub, + completed_by_name=EXCLUDED.completed_by_name, + completed_by_client_id=EXCLUDED.completed_by_client_id, + completed_at=NOW() + RETURNING *`, + [ + id, threadId, resultValue, JSON.stringify(sources), summary, + actor.sub, actor.name, actor.clientId + ] + ); + + await pool.query("UPDATE story_threads SET updated_at=NOW() WHERE id=$1", [threadId]); + return result.rows[0]; +} + +export async function markStoryThreadStatus( + id: string, + status: string, + actorLabel: string +) { + if (!STORY_STATUSES.has(status)) throw new Error("Invalid story status"); + + if (status === "review" || status === "develop") { + const check = await pool.query( + `SELECT result FROM story_research_checks + WHERE thread_id=$1 AND check_type='counter_evidence'`, + [id] + ); + if (!check.rowCount || !new Set(["found", "none_found"]).has(check.rows[0].result)) { + throw new Error("Counter-evidence research must be completed before review/develop"); + } + } + + const result = await pool.query( + "UPDATE story_threads SET status=$2,updated_at=NOW() WHERE id=$1 RETURNING *", + [id, status] + ); + if (!result.rowCount) return null; + + await pool.query( + `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) + VALUES($1,'story_thread.status_changed','story_thread',$2,$3::jsonb)`, + [randomUUID(), id, JSON.stringify({ status, actorLabel })] + ); + + return result.rows[0]; +} + +export async function seedInitialStoryThread() { + const existing = await pool.query( + "SELECT id FROM story_threads WHERE title=$1 LIMIT 1", + ["NZ labour market — August 2026"] + ); + if (existing.rowCount) return existing.rows[0]; + + const evidence = await pool.query( + `SELECT e.id AS evidence_id,e.signal_id,s.source_id,s.title,s.summary + FROM evidence e + JOIN signals s ON s.id=e.signal_id + WHERE s.url ILIKE '%employment-indicators-august-2026%' + ORDER BY e.retrieved_at DESC + LIMIT 1` + ); + if (!evidence.rowCount) return null; + + const message = await pool.query( + `SELECT id,title,body + FROM agent_messages + WHERE title='Counter-evidence pass: August employment lead' + ORDER BY created_at DESC + LIMIT 1` + ); + + const actor: StoryActor = { + sub: "system:story-bootstrap", + name: "Story Bootstrap", + clientId: "media-intelligence-worker" + }; + + await pool.query("BEGIN"); + try { + const thread = await createStoryThread({ + title: "NZ labour market — August 2026", + question: "How should rising filled jobs be interpreted alongside unemployment, underutilisation and economic forecasts?" + }, actor); + + const primary = await addStoryNode(thread.id, { + nodeType: "evidence", + title: "Stats NZ: Employment indicators August 2026", + summary: "Primary stored employment signal for this research thread.", + sourceId: evidence.rows[0].source_id, + signalId: evidence.rows[0].signal_id, + evidenceId: evidence.rows[0].evidence_id + }, actor); + + const claim = await addStoryNode(thread.id, { + nodeType: "claim", + title: "Filled jobs are increasing", + summary: "Candidate interpretation from the August filled-jobs signal; this is not a final editorial conclusion." + }, actor); + + const counter = await addStoryNode(thread.id, { + nodeType: "counterclaim", + title: "Filled jobs alone do not establish labour-market strengthening", + summary: "Counter-hypothesis: unemployment, underutilisation, participation, population and forward-looking indicators may qualify the headline signal." + }, actor); + + const question = await addStoryNode(thread.id, { + nodeType: "question", + title: "What evidence contradicts the apparent improvement?", + summary: "Required 360° research branch before this thread can move to editorial review." + }, actor); + + await addStoryEdge(thread.id, { + fromNodeId: primary.id, + toNodeId: claim.id, + relationship: "supports", + reason: "The Stats NZ filled-jobs signal is the primary evidence behind the candidate claim." + }, actor); + + await addStoryEdge(thread.id, { + fromNodeId: counter.id, + toNodeId: claim.id, + relationship: "qualifies", + reason: "Filled-jobs growth needs to be interpreted alongside wider labour-market measures." + }, actor); + + await addStoryEdge(thread.id, { + fromNodeId: question.id, + toNodeId: counter.id, + relationship: "investigates", + reason: "The thread explicitly requires a counter-evidence search." + }, actor); + + if (message.rowCount) { + const context = await addStoryNode(thread.id, { + nodeType: "context", + title: "Counter-evidence research pass", + summary: message.rows[0].body, + messageId: message.rows[0].id + }, actor); + + await addStoryEdge(thread.id, { + fromNodeId: context.id, + toNodeId: counter.id, + relationship: "supports", + reason: "Prior research identified wider labour-market measures that qualify the filled-jobs interpretation." + }, actor); + + await completeCounterEvidenceCheck(thread.id, { + result: "found", + sourcesChecked: ["Stats NZ", "Treasury", "BNZ"], + summary: "Material qualifying evidence was identified. Broader RBNZ and MBIE cross-checking remains desirable when those upstream sources are reachable." + }, actor); + } + + await pool.query("COMMIT"); + + await postSystemMessageOnce({ + dedupeKey: `story-thread-bootstrap:${thread.id}`, + title: "Story graph initialized", + body: `Created the first 360° research thread: ${thread.title}. The graph includes the primary Stats NZ evidence, candidate claim, counterclaim, explicit counter-evidence question and persisted research check.`, + severity: "notice", + kind: "update", + relatedType: "general", + relatedId: thread.id + }); + + return thread; + } catch (error) { + await pool.query("ROLLBACK"); + throw error; + } +} + + export interface AgentMessageActor { sub: string; name: string; diff --git a/src/index.ts b/src/index.ts index af0e23e..79f0b89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,11 +2,15 @@ import express from "express"; import { acknowledgeAgentMessage, getOpportunity, + getStoryThread, listAgentMessages, listOpportunities, + listStoryThreads, markOpportunity, + markStoryThreadStatus, migrate, pool, + seedInitialStoryThread, seedSources } from "./db.js"; import { @@ -48,6 +52,64 @@ function nzTime(value: unknown) { } } + +function storyGraphSvg(story: any) { + const nodes = Array.isArray(story.nodes) ? story.nodes : []; + const edges = Array.isArray(story.edges) ? story.edges : []; + const width = 1000; + const height = 640; + const cx = width / 2; + const cy = height / 2 + 20; + const rx = 390; + const ry = 235; + const positions = new Map(); + + nodes.forEach((node: any, index: number) => { + const angle = -Math.PI / 2 + (Math.PI * 2 * index) / Math.max(nodes.length, 1); + positions.set(node.id, { + x: cx + Math.cos(angle) * rx, + y: cy + Math.sin(angle) * ry + }); + }); + + const edgeSvg = edges.map((edge: any) => { + const from = positions.get(edge.from_node_id); + const to = positions.get(edge.to_node_id); + if (!from || !to) return ""; + const mx = (from.x + to.x) / 2; + const my = (from.y + to.y) / 2; + return ` + + ${escapeHtml(edge.relationship)} + `; + }).join(""); + + const nodeSvg = nodes.map((node: any) => { + const p = positions.get(node.id)!; + const title = String(node.title ?? ""); + const short = title.length > 36 ? title.slice(0, 33) + "…" : title; + return ` + + ${escapeHtml(node.title)} — ${escapeHtml(node.node_type)} + + ${escapeHtml(node.node_type)} + ${escapeHtml(short)} + + `; + }).join(""); + + return ` + + + + STORY THREAD + ${escapeHtml(String(story.title).slice(0, 38))} + + ${edgeSvg} + ${nodeSvg} + `; +} + app.get("/health", async (_req, res) => { try { await pool.query("SELECT 1"); @@ -106,6 +168,100 @@ app.get("/api/mcp", requireMcpAuth, (_req, res) => { }); }); + +app.post("/stories/:id/status", requireWebAuth, async (req, res) => { + const user = (req as any).user ?? {}; + const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user"); + try { + await markStoryThreadStatus(String(req.params.id), String(req.body.status ?? ""), `web:${identity}`); + return res.redirect(`/stories/${encodeURIComponent(String(req.params.id))}`); + } catch (error) { + return res.status(400).type("text").send(error instanceof Error ? error.message : String(error)); + } +}); + +app.get("/stories/:id", requireWebAuth, async (req, res) => { + const story = await getStoryThread(String(req.params.id)); + if (!story) return res.status(404).send("Story thread not found"); + + const user = (req as any).user ?? {}; + const identity = user.name || user.preferred_username || user.email || "Signed-in user"; + const nodes = Array.isArray(story.nodes) ? story.nodes : []; + const edges = Array.isArray(story.edges) ? story.edges : []; + const checks = Array.isArray(story.research_checks) ? story.research_checks : []; + const counter = checks.find((c:any)=>c.check_type === "counter_evidence"); + + res.type("html").send(` + +${escapeHtml(story.title)} — Story Research + +
← Story Threads

${escapeHtml(story.title)}

${escapeHtml(story.question)}

+
${escapeHtml(identity)} · Sign out
+
+Status: ${escapeHtml(story.status)} · +Nodes: ${nodes.length} · +Edges: ${edges.length} +
+${["researching","review","develop","closed"].map(s=>`
`).join("")} +
+
+
+Mandatory counter-evidence pass: +${counter ? `${escapeHtml(counter.result)} · sources checked: ${escapeHtml((counter.sources_checked ?? []).join(", "))}

${escapeHtml(counter.summary)}

` : "not completed"} +
+
${storyGraphSvg(story)}
+

Research nodes

+
+${nodes.map((n:any)=>`
+${escapeHtml(n.node_type)} +

${escapeHtml(n.title)}

+

${escapeHtml(n.summary)}

+
${n.provenance_source_id ? `source ${escapeHtml(n.provenance_source_id)} · ` : ""}${n.confidence != null ? `confidence ${escapeHtml(n.confidence)} · ` : ""}${escapeHtml(n.created_by_name)}
+${n.evidence_url ? `

Open evidence ↗

` : ""} +
`).join("")} +
+

Relationships

+${edges.length ? edges.map((e:any)=>`
${escapeHtml(e.relationship)}
${escapeHtml(e.from_node_id.slice(0,8))} → ${escapeHtml(e.to_node_id.slice(0,8))}

${escapeHtml(e.reason)}

`).join("") : '
No graph edges yet.
'} +`); +}); + +app.get("/stories", requireWebAuth, async (req, res) => { + const stories = await listStoryThreads({ limit: 100 }); + const user = (req as any).user ?? {}; + const identity = user.name || user.preferred_username || user.email || "Signed-in user"; + + res.type("html").send(` + +Story Threads — Media Intelligence + +
← Dashboard

360° Story Threads

Evidence networks that require an explicit counter-evidence pass before editorial review.

+
${escapeHtml(identity)} · Sign out
+${stories.length ? stories.map((s:any)=>`
+${escapeHtml(s.title)} +

${escapeHtml(s.question)}

+
${escapeHtml(s.status)} · ${s.node_count} nodes · ${s.edge_count} edges · counter-evidence: ${escapeHtml(s.counter_evidence_result ?? "pending")}
+
`).join("") : '
No story threads yet.
'} +`); +}); + app.post("/messages/:id/ack", requireWebAuth, async (req, res) => { const user = (req as any).user ?? {}; const identity = String( @@ -204,7 +360,7 @@ app.get("/", requireWebAuth, async (req, res) => { COUNT(sig.id)::int AS signal_count FROM sources s LEFT JOIN signals sig ON sig.source_id=s.id - GROUP BY s.id,s.name,s.last_synced_at,s.last_error + GROUP BY s.id,s.name,s.last_synced_at,s.last_error,s.baseline_complete ORDER BY s.id` ); const counts = await pool.query(` @@ -212,7 +368,8 @@ app.get("/", requireWebAuth, async (req, res) => { (SELECT COUNT(*) FROM signals) AS signals, (SELECT COUNT(*) FROM opportunities WHERE status <> 'closed') AS opportunities, (SELECT COUNT(*) FROM opportunities WHERE status='new') AS new_opportunities, - (SELECT COUNT(*) FROM agent_messages WHERE acknowledged_at IS NULL) AS unread_messages + (SELECT COUNT(*) FROM agent_messages WHERE acknowledged_at IS NULL) AS unread_messages, + (SELECT COUNT(*) FROM story_threads WHERE status <> 'closed') AS story_threads `); const messages = await listAgentMessages({ acknowledged: false, limit: 10 }); const c = counts.rows[0] ?? {}; @@ -239,7 +396,7 @@ a{color:inherit} button{margin-top:12px;padding:7px 11px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}
-

Media Intelligence MVP

Human-led NZ public-source monitoring behind a dedicated MCP.

Open Opportunities →

+

Media Intelligence MVP

Human-led NZ public-source monitoring behind a dedicated MCP.

Open Opportunities → · Open 360° Story Threads →

${escapeHtml(identity)}
Sign out
@@ -247,6 +404,7 @@ button{margin-top:12px;padding:7px 11px;border:1px solid #9ca3af;border-radius:7
${c.opportunities ?? 0}opportunities
${c.new_opportunities ?? 0}new
${c.unread_messages ?? 0}agent messages
+
${c.story_threads ?? 0}story threads

Agent Activity

${messages.length @@ -269,6 +427,7 @@ ${sourceRows.rows.map((s:any)=>`
${s.name}
async function start() { await migrate(); await seedSources(SOURCES); + await seedInitialStoryThread(); const port = Number(process.env.PORT ?? 3000); app.listen(port, "0.0.0.0", () => { diff --git a/src/mcp.ts b/src/mcp.ts index 67acc92..c1bd62a 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -1,12 +1,18 @@ import type { Request, Response } from "express"; import { acknowledgeAgentMessage, + addStoryEdge, + addStoryNode, + completeCounterEvidenceCheck, + createStoryThread, getAgentMessage, getEvidence, getOpportunity, + getStoryThread, listAgentMessages, listOpportunities, listSources, + listStoryThreads, markOpportunity, postAgentMessage, searchEvidence, @@ -144,6 +150,93 @@ const tools = [ required: ["id"], additionalProperties: false } + }, + { + name: "story_threads_list", + description: "List 360-degree research story threads and their graph/research status.", + inputSchema: { + type: "object", + properties: { + status: { type: "string", enum: ["researching", "review", "develop", "closed"] }, + limit: { type: "integer", minimum: 1, maximum: 100 } + }, + additionalProperties: false + } + }, + { + name: "story_thread_get", + description: "Get one story thread with all graph nodes, labelled edges and research checks.", + inputSchema: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + additionalProperties: false + } + }, + { + name: "story_thread_create", + description: "Create a candidate 360-degree research thread. This does not approve, draft or publish a story.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", maxLength: 220 }, + question: { type: "string", maxLength: 1200 } + }, + required: ["title"], + additionalProperties: false + } + }, + { + name: "story_node_add", + description: "Add a research node to a story graph with optional provenance links to an existing signal, evidence record, opportunity or agent message.", + inputSchema: { + type: "object", + properties: { + thread_id: { type: "string" }, + node_type: { type: "string", enum: ["signal","evidence","claim","counterclaim","trend","forecast","actual","question","context","organisation","person","place","metric"] }, + title: { type: "string", maxLength: 240 }, + summary: { type: "string", maxLength: 8000 }, + source_id: { type: "string" }, + signal_id: { type: "string" }, + evidence_id: { type: "string" }, + opportunity_id: { type: "string" }, + message_id: { type: "string" }, + confidence: { type: "number", minimum: 0, maximum: 1 } + }, + required: ["thread_id","node_type","title"], + additionalProperties: false + } + }, + { + name: "story_edge_add", + description: "Connect two nodes in a story graph with an explicit evidentiary relationship.", + inputSchema: { + type: "object", + properties: { + thread_id: { type: "string" }, + from_node_id: { type: "string" }, + to_node_id: { type: "string" }, + relationship: { type: "string", enum: ["supports","contradicts","qualifies","explains","correlates_with","precedes","updates","supersedes","same_subject","regional_breakdown_of","forecast_for","actual_for","investigates"] }, + reason: { type: "string", maxLength: 2000 } + }, + required: ["thread_id","from_node_id","to_node_id","relationship"], + additionalProperties: false + } + }, + { + name: "story_counter_check_complete", + description: "Record the mandatory counter-evidence pass for a story thread, including sources checked and whether material counter-evidence was found.", + inputSchema: { + type: "object", + properties: { + thread_id: { type: "string" }, + result: { type: "string", enum: ["found","none_found"] }, + sources_checked: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 50 }, + summary: { type: "string", maxLength: 8000 } + }, + required: ["thread_id","result","sources_checked","summary"], + additionalProperties: false + } } ]; @@ -196,6 +289,34 @@ async function callTool(name: string, args: any, actor: ReturnType