Proactive Research
The research system runs autonomous background research on topics derived from goals, projects, and heartbeat topics. It maintains a prioritized topic queue, executes LLM-powered analysis, stores findings in daily JSONL files, and synthesizes accumulated findings into coherent summaries.
Warning β current state (instrumentation-lying): the research loop runs on schedule (
status: successevery 3h through 2026-06-21, ~6β8 min/run), the queue keeps growing (now 5,782 topics, 3.3 MB), andlast_researched_atkeeps updating β yet no new findings JSONL has been written since 2026-04-05 and no synthesis since 2026-04-04. The loop is green while persisting zero findings: either the LLM analysis returns emptyfindings(whichresearch_topic()treats as a no-op, see Research Execution) or the writes are silently failing. A reimplementer should treat the storage write path described below as the intended behavior, not the live one, and add an alert that ties "loop ran" to "findings written." See Sentinel for the watchdog pattern this gap should be wired into.
Why Proactive Research?
Silicon-Zack needs to stay informed on topics that matter to bio-Zack without waiting for explicit requests. The research system:
- Extracts topics automatically from active goals, projects, and heartbeat topics
- Prioritizes intelligently using age decay, source weighting, and research fatigue
- Avoids repetition by feeding previous findings back into prompts
- Synthesizes over time - individual findings accumulate into coherent summaries
- Surfaces high-priority findings via Telegram when they warrant interruption
System Architecture
TOPIC EXTRACTION
+------------------+ +------------------+ +------------------+
| Heartbeat | | Active Goals | | Projects |
| Tag Mapping | | LLM Extraction | | LLM Extraction |
+--------+---------+ +--------+---------+ +--------+---------+
| | |
+------------------------+------------------------+
|
v
+-------------------------------+
| TOPIC QUEUE |
| data/research/queue.json |
| - Deduplication by query |
| - Priority scoring |
| - Auto-retirement |
+---------------+---------------+
|
v
+-------------------------------+
| RESEARCH EXECUTION |
| - Top 5 due topics per loop |
| - LLM analysis (deepseek-v4-flash) |
| - Novelty checking |
+---------------+---------------+
|
+------------+------------+
| |
All findings 2+ findings exist
| |
v v
+-----------+ +-------------------+
| STORE | | SYNTHESIZE |
| findings/| | syntheses/ |
| YYYY.jsonl| | {topic_id}.json |
+-----------+ +-------------------+
|
(if notify + score >= 80)
|
v
+-------------------+
| TELEGRAM ALERT |
+-------------------+
Topic Queue
Queue File
Location: data/research/queue.json
{
"topics": [
{
"id": "f8e57d42-...",
"query": "VO2 max training protocol improvement strategies",
"source": "goal",
"source_id": "maintain-cardiovascular-fitness-001",
"priority": 0.7,
"created_at": "2026-02-13T05:20:42Z",
"last_researched_at": "2026-02-16T03:52:13Z",
"research_count": 3,
"status": "active",
"context": { "goal_title": "Improve VO2 Max" },
"findings_count": 3,
"tags": ["fitness", "endurance", "training"]
}
]
}
Topic Sources
Location: scripts/research/topic_sources.py
Topics are extracted from three sources during each queue refresh:
| Source | Extractor | Priority Range | Method |
|---|---|---|---|
| Heartbeat | extract_from_heartbeat() |
0.4 | Tag-based mapping via RESEARCHABLE_TAG_MAP |
| Goals | extract_from_goals() |
0.3-0.8 | LLM generates research queries from goal descriptions |
| Projects | extract_from_projects() |
0.3-0.6 | LLM generates competitive intelligence queries |
Heartbeat tag mapping is deterministic - specific tags map to predefined research queries:
RESEARCHABLE_TAG_MAP = {
"rwgps-game": ["location-based cycling games", "gamification fitness apps retention"],
"ceo-mode": ["CEO transition founder playbooks", "engineering manager to CEO frameworks"],
"health": ["endurance athlete recovery optimization"],
# ...
}
Goal and project extraction uses LLM inference (research_extract task type) to generate 3-8 research queries from active goal descriptions or project registries. Falls back to simple title-based queries if the LLM call fails.
Biographical topics (type project_interview) are filtered out. Only active status heartbeat topics are considered.
Topic Statuses
| Status | Meaning |
|---|---|
pending |
New topic, never researched |
active |
Has been researched at least once |
completed |
Manually marked as done |
retired |
Auto-retired or manually retired |
Priority Scoring
Effective priority determines research order. Calculated as:
effective = base_priority * age_decay * freshness * research_decay * source_multiplier
Factors:
| Factor | Effect |
|---|---|
| Age decay | Linear decay to 0.1 over 30 days |
| Freshness boost | 1.2x for topics < 3 days old, 1.0x for 3-7 days, 0.8x after |
| Research fatigue | Each research reduces priority by 20% (floor 0.1) |
| Source multiplier | conversation: 1.3, goal: 1.2, media_followup: 1.0, project: 0.9, heartbeat: 0.7 |
Auto-Retirement
Topics are automatically retired when: - Researched 5+ times with 0 findings (nothing to find) - Older than 30 days with 3+ research attempts (stale)
Retirement only flips a topic's status to retired; retired topics are filtered out of get_due_topics() but are not deleted from queue.json. There is no topic-count cap, so the queue file grows without bound β in the live system it has reached 5,782 topics / 3.3 MB. Since get_due_topics() loads and scans the entire file every loop (and add_topic() does a full linear dedup scan on each insert), this is an O(n) cost that grows with queue size; a reimplementer should consider periodic pruning of retired topics or an indexed store.
Deduplication
add_topic() deduplicates by case-insensitive query matching. If a matching non-retired topic exists, it updates the priority (if new is higher) and merges tags.
Minimum Interval
A topic cannot be researched more than once per 12 hours.
Research Execution
Location: scripts/research/researcher.py
Note:
research_topic()returns early (no finding stored) whenever the parsed LLM response has an emptyfindingslist or fails to JSON-parse. This is the most likely cause of the silent-success state noted at the top of this page β the loop completes and reportssuccesswhileappend_finding()is never reached.
The Research Loop
research_loop() is the main entry point, called by the scheduler:
- Refresh queue - Run all topic extractors, deduplicate, update queue
- Get due topics - Top 5 highest-priority topics that pass filters
- Research each - LLM analysis with novelty checking
- Update metadata - Increment research_count, update last_researched_at, transition pending to active
- Synthesize - For topics with 2+ findings, generate/update synthesis
Research Prompt
Each topic is analyzed using a structured prompt that includes: - The research query and context - Previous findings (up to 15 items from last 30 days) for novelty avoidance - Instructions for structured JSON output
The LLM returns:
{
"findings": ["Key finding 1", "Key finding 2"],
"actionable_insights": ["Recommendation 1"],
"relevance_score": 85,
"confidence": "medium",
"notify": false,
"notify_reason": "",
"source_title": "Analysis: Topic Name"
}
High-Priority Surfacing
Findings with notify: true AND relevance_score >= 80 are sent to Telegram via the proactive outreach queue (researcher.py:_surface_finding):
enqueue_message(
trigger="research_finding",
message=formatted_finding, # query + first finding + notify_reason + score
send_at=datetime.now(),
dedup_key=f"research-{finding.id}",
topic_id="0", # routes to the default Telegram topic
)
See Proactive Outreach for how enqueue_message and dedup keys work.
Rate Limiting
RESEARCH_CONFIG = {
"max_topics_per_loop": 5,
"max_findings_per_topic": 10,
"max_tokens_per_topic": 50_000,
"loop_interval_hours": 3,
"active_hours": (8, 22), # 8am-10pm PT
"max_notifications_daily": 5,
"max_research_cost_daily_usd": 0.50,
}
Inference Routing
Research uses cheap, high-volume inference rather than a frontier model. All three research task types route to deepseek/deepseek-v4-flash in .aloop/config.json (current as of this writing β for the live routing map and rationale see Model Guidance, which is auto-maintained):
| Task Type | Purpose |
|---|---|
research_extract |
Extract research queries from goals/projects |
research_analyze |
Analyze a topic and produce findings |
research_synthesize |
Synthesize accumulated findings |
Storage
Location: scripts/research/storage.py
Findings
Daily JSONL files at data/research/findings/YYYY-MM-DD.jsonl.
Each line is a ResearchFinding:
{
"id": "3005c4f6-...",
"topic_id": "eb2a780f-...",
"query": "founder to CEO transition delegation frameworks",
"source_url": "llm-knowledge",
"source_title": "Founder-to-CEO Transition: Leadership Frameworks",
"findings": ["Finding 1", "Finding 2", "Finding 3"],
"actionable_insights": ["Action 1", "Action 2"],
"relevance_score": 85,
"confidence": "medium",
"notify": false,
"notify_reason": "",
"model": "deepseek/deepseek-v4-flash",
"researched_at": "2026-02-24T02:59:36Z",
"tokens_in": 10844,
"tokens_out": 386,
"cost_usd": 0.0037
}
source_url is currently always "llm-knowledge" since the system uses LLM training knowledge. Web search integration is planned for the future.
Syntheses
Per-topic JSON files at data/research/syntheses/{topic_id}.json.
{
"topic_id": "799fab46-...",
"query": "founder to CEO transition delegation frameworks time allocation",
"synthesis": "Multi-paragraph summary of all findings...",
"key_findings": [
"Hiring executives in first 30 days is highest-leverage activity",
"Delegation failure stems from expertise trap, not trust",
"Quarterly OKRs with 'what I will stop doing' formalize role transition"
],
"next_steps": [
"Research hiring frameworks for first-30-day executive recruitment",
"Investigate time audit methodologies for CEO transition"
],
"last_updated": "2026-02-24T17:40:33Z",
"total_research_count": 2,
"total_findings_count": 8,
"total_cost_usd": 0.0052
}
Syntheses are regenerated whenever a topic has 2+ findings and is researched again. The synthesis prompt includes up to 50 finding items from the last 90 days.
Index File
data/research/index.json contains metadata for manually curated research artifacts (papers, ideas, experiments) separate from the automated research pipeline.
Data Layout
data/research/
βββ queue.json # Topic queue (all topics with metadata)
βββ index.json # Manual research index (papers, ideas)
βββ papers/ # Manually curated research papers
β βββ 2512.05765-missing-layer-agi.md
βββ findings/ # Daily JSONL finding files
β βββ 2026-02-13.jsonl # first file
β βββ 2026-02-14.jsonl
β βββ ... # 52 files, 2026-02-13 .. 2026-04-05 (none since)
βββ syntheses/ # Per-topic synthesis JSON
βββ 799fab46-...-8af90ee8ec23.json
βββ d9a8ae0b-...-a5d3eaf78c66.json
βββ ... # 676 files (newest written 2026-04-04)
Note: the findings/ and syntheses/ directories stopped growing in early April 2026 even though the loop still runs β see the warning at the top of this page. The counts above are a snapshot; in a healthy system they would grow daily.
Scheduling
The research loop runs every 3 hours during active hours (8am-10pm PT), managed by VitaScheduler._research_loop().
| Setting | Value |
|---|---|
| Interval | 3 hours |
| Active hours | 8am-10pm PT |
| Initial delay | 180 seconds |
| Timeout | 600 seconds (10 min) |
| Execution tracking | track_execution("research") |
VitaScheduler._run_research() delegates to run_research_cycle() (imported as run_research_cycle_inference) in api/src/services/scheduler_inference_jobs.py. That helper is where the subprocess is actually spawned, with PYTHONPATH set to the scripts/ directory:
# api/src/services/scheduler_inference_jobs.py
async def run_research_cycle(*, project_root, env):
async with track_execution("research"):
proc = await asyncio.create_subprocess_exec(
"python", "-c",
"from research import research_loop; import asyncio; asyncio.run(research_loop())",
cwd=str(project_root),
env=_with_scheduler_pythonpath(env, project_root),
...
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=600)
Note: research is one of a cluster of inference jobs that share this subprocess-spawn-plus-
track_executionpattern inscheduler_inference_jobs.pyβ alongsiderun_speculative_computation(),run_deep_analysis_cycle(), andrun_daily_synthesis()(the media deep-analysis and synthesis jobs). If you reimplement the scheduler side, treat these as one family: each spins up a short-livedpython -c "from <module> import β¦"subprocess, wraps it intrack_execution(<name>), and returns(ok, output_or_error).
Manual Trigger
curl -X POST http://localhost:33800/api/v1/scheduler/trigger/research
The endpoint is POST /api/v1/scheduler/trigger/{task_name} (router prefix /api/v1/scheduler); task_name=research maps to scheduler.run_research_now(), which awaits _run_research() immediately.
Python API
Add a topic manually
from scripts.research import add_topic
topic = add_topic(
query="zone 2 training heart rate drift",
source="conversation",
source_id="session-2026-02-24",
priority=0.7,
context={"reason": "bio-zack mentioned HR drift on rides"},
tags=["cycling", "training"],
)
Get recent findings
from scripts.research import get_findings
# Last 24 hours (default)
findings = get_findings()
# Last 72 hours
findings = get_findings(hours=72)
Run the full loop
from scripts.research import research_loop
import asyncio
stats = asyncio.run(research_loop())
# stats = {
# "topics_researched": 3,
# "findings_count": 3,
# "syntheses_updated": 2,
# "cost_usd": 0.012,
# "queue_refresh": {"heartbeat_extracted": 6, "goals_extracted": 5, ...}
# }
Queue management
from scripts.research.queue import load_queue, get_due_topics, retire_topic
# List all topics
topics = load_queue()
for t in topics:
print(f"{t.status}: {t.query} (researched {t.research_count}x)")
# Get next batch
due = get_due_topics(max_count=3)
# Retire a topic
retire_topic("topic-uuid-here")
Synthesis
from scripts.research.researcher import synthesize_topic
import asyncio
synthesis = asyncio.run(synthesize_topic("topic-uuid-here"))
if synthesis:
print(synthesis.synthesis)
print(synthesis.key_findings)
Data Types
ResearchTopic
| Field | Type | Description |
|---|---|---|
id |
str | UUID |
query |
str | The research query |
source |
str | heartbeat, goal, project, conversation, media_followup |
source_id |
str | ID of the originating item |
priority |
float | Base priority 0.0-1.0 |
created_at |
str | ISO timestamp |
last_researched_at |
str or None | Last research timestamp |
research_count |
int | Times researched |
status |
str | pending, active, completed, retired |
context |
dict | Source-specific context for the LLM |
findings_count |
int | Total findings produced |
tags |
list[str] | Grouping tags |
ResearchFinding
| Field | Type | Description |
|---|---|---|
id |
str | UUID |
topic_id |
str | Parent topic UUID |
query |
str | Research query |
source_url |
str | Currently "llm-knowledge" |
source_title |
str | LLM-generated title |
findings |
list[str] | 2-4 key findings |
actionable_insights |
list[str] | 1-2 recommendations |
relevance_score |
int | 0-100 |
confidence |
str | high, medium, low |
notify |
bool | Whether to alert via Telegram |
notify_reason |
str | Why notification warranted |
model |
str | Model used |
researched_at |
str | ISO timestamp |
tokens_in |
int | Input tokens |
tokens_out |
int | Output tokens |
cost_usd |
float | Inference cost |
ResearchSynthesis
| Field | Type | Description |
|---|---|---|
topic_id |
str | Parent topic UUID |
query |
str | Research query |
synthesis |
str | 2-3 paragraph summary |
key_findings |
list[str] | Top 3-5 findings |
next_steps |
list[str] | 1-3 follow-up suggestions |
last_updated |
str | ISO timestamp |
total_research_count |
int | Total research sessions |
total_findings_count |
int | Total findings across all sessions |
total_cost_usd |
float | Cumulative inference cost |
File Locations
| File | Purpose |
|---|---|
scripts/research/__init__.py |
Public API: research_loop, add_topic, get_findings |
scripts/research/types.py |
Data classes (ResearchTopic, ResearchFinding, ResearchSynthesis) |
scripts/research/queue.py |
Queue management, priority scoring, auto-retirement |
scripts/research/storage.py |
JSONL/JSON persistence for findings and syntheses |
scripts/research/researcher.py |
Core execution: research_topic, synthesize_topic, research_loop |
scripts/research/topic_sources.py |
Topic extraction from heartbeat, goals, projects |
api/src/scheduler.py |
Scheduler loop: _research_loop (timing/window), _run_research (delegates to the inference-jobs helper), run_research_now |
api/src/services/scheduler_inference_jobs.py |
run_research_cycle() β spawns the actual research subprocess (and sibling speculative/deep-analysis/synthesis jobs) |
api/src/routers/scheduler.py |
Manual trigger endpoint (/api/v1/scheduler/trigger/{task_name}) |
.aloop/config.json |
Inference routing for research_extract / research_analyze / research_synthesize task types |
data/research/queue.json |
Topic queue |
data/research/findings/*.jsonl |
Daily findings |
data/research/syntheses/*.json |
Per-topic syntheses |
data/research/index.json |
Manual research index |
Known Limitations
- Silent success / no findings written - The loop reports
status: successevery cycle but has written no findings since 2026-04-05 and no synthesis since 2026-04-04 (see the warning at the top of this page). Nothing ties "loop ran" to "findings persisted," so the failure is invisible to the execution log. This is the highest-priority limitation for a reimplementer to fix β wire a watchdog on findings-write recency, not just loop success. - LLM-only sources - Uses LLM training knowledge only (
source_urlis hardcoded to"llm-knowledge"). No web search or live data fetching. The codebase notes web search integration as a future addition. - No API endpoints for browsing - Research data has no dedicated REST endpoints. Access is through Python imports or file inspection.
- Cost tracking is per-finding - No aggregated daily budget enforcement like the media system has. The
max_research_cost_daily_usdconfig exists but is read by nothing in the loop, so it is not enforced. - Unbounded queue growth - Retired topics stay in
queue.json; there is no topic-count cap or pruning. The file is scanned in full on every loop and everyadd_topic(). - Findings accumulate indefinitely - No rotation or archival of old findings files.
- Synthesis overwrites - Each synthesis run replaces the previous one rather than appending history.
Where to go next
- Sentinel β the watchdog pattern this loop's silent-success gap should be wired into
- Model Guidance β live inference routing for the
research_*task types - Proactive Outreach β how high-priority findings reach Telegram
- Media β sibling inference jobs (deep-analysis, daily synthesis) that share the same subprocess pattern
- Heartbeat / Project Registry β sources the topic queue draws from