Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions ai_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
SCRIPT_DIR = Path(__file__).parent
sys.path.insert(0, str(SCRIPT_DIR))

from config import OLLAMA_MODEL, OLLAMA_API_URL, TEMPERATURE, TIMEOUT, KEEP_ALIVE, NUM_CTX, VAULT_PATH # noqa: E402
from config import OLLAMA_MODEL, OLLAMA_API_URL, TEMPERATURES, TIMEOUT, KEEP_ALIVE, NUM_CTX, VAULT_PATH # noqa: E402

# terminal colors
R = "\033[0m"
Expand Down Expand Up @@ -50,7 +50,7 @@ def check_ollama() -> bool:
def check_vault() -> bool:
"""
Check if the configured vault path exists.
Prints a friendly error if not found.
Prints a error if not found.

Returns:
True if vault exists, False otherwise.
Expand Down Expand Up @@ -83,32 +83,36 @@ def get_backend() -> str:
return "ollama"


def call_ai(prompt: str, backend: str = "ollama", timeout: int = None) -> str:
def call_ai(prompt: str, backend: str = "ollama", timeout: int = None, temperature: float = None) -> str:
"""
Send a prompt to the configured AI backend and return the response text.

Args:
prompt: The full prompt string to send to the model.
backend: Which backend to use. Currently only 'ollama' is supported.
timeout: Override the default timeout from config.py (in seconds).
temperature: Override the global temperature for this specific call.

Returns:
The model's response as a stripped string.
"""
return _call_ollama(prompt, timeout or TIMEOUT)
return _call_ollama(prompt, timeout or TIMEOUT, temperature)


def _call_ollama(prompt: str, timeout: int) -> str:
def _call_ollama(prompt: str, timeout: int, temperature: float = None) -> str:
"""
Internal function that makes the actual HTTP request to the Ollama API.
"""

Args:
prompt: The prompt to send.
timeout: Max seconds to wait for a response.
global_temp = TEMPERATURES.get("default", 0.2)
raw_temp = temperature if temperature is not None else global_temp

try:
final_temp = max(0.0, min(1.0, float(raw_temp)))
except (ValueError, TypeError):
print(f"\n[WARNING] Invalid temperature '{raw_temp}' in config. Defaulting to 0.2")
final_temp = 0.2

Returns:
The model's response text, stripped of leading/trailing whitespace.
"""
response = requests.post(
OLLAMA_API_URL,
json={
Expand All @@ -117,7 +121,7 @@ def _call_ollama(prompt: str, timeout: int) -> str:
"stream": False,
"keep_alive": KEEP_ALIVE,
"options": {
"temperature": TEMPERATURE,
"temperature": final_temp,
"top_p": 0.9,
"repeat_penalty": 1.1,
"num_ctx": NUM_CTX,
Expand Down
50 changes: 47 additions & 3 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,21 @@
# -------------------------------------------------------------
OLLAMA_MODEL = "llama3.1:8b" # Ollama model to use

TEMPERATURE = 0.2 # Controls randomness. Lower = more factual, less hallucination.
# Range: 0.0 (deterministic) to 1.0 (creative). Default: 0.2
# Temperature controls the randomness and creativity of the AI.
# - 0.0 to 0.3: Rigid, factual, analytical (Good for summaries/coding)
# - 0.4 to 0.7: Balanced, conversational (Good for general writing)
# - 0.8 to 1.0: Highly creative, unpredictable (Good for brainstorming)
TEMPERATURES = {
# The global fallback used if a script doesn't have a specific setting.
"default": 0.2,

# Specific script overrides.
# Replace 'None' with a float to override the default.
"insights": None, # generate_insights.py
"briefing": None, # morning_briefing.py
"recap": None, # study_recap.py
"txt": None, # txt_to_notes.py
}

# -------------------------------------------------------------
# API Configuration
Expand Down Expand Up @@ -36,7 +49,7 @@

MAX_NOTE_CHARS = 2000 # Max characters read per note. Higher = more detail but slower.

MAX_FILE_SIZE = 1_000_000 # 1MB max per file, skip larger ones
MAX_FILE_SIZE = 1_000_000 # 1MB max per file, skip larger ones

#--------------------------------------------------------------
# Ignore Folders
Expand All @@ -50,3 +63,34 @@
# "Templates",
# "Archive",
]

#--------------------------------------------------------------
# Tagging System (generate_insights.py)
#--------------------------------------------------------------
CANDIDATES = {
"productivity": ["productiv", "task", "goal", "work", "focus"],
"mood": ["mood", "emotion", "feel", "stress", "anxiet", "happy"],
"philosophy": ["meaning", "values", "purpose", "reflect", "life"],
"habits": ["habit", "routine", "pattern", "repeat", "daily"],
"health": ["health", "sleep", "exercise", "energy", "body"],
"projects": ["project", "build", "code", "ship", "launch", "develop"],
"relationships": ["friend", "family", "partner", "social", "connect"],
# "finances": ["money", "budget", "spend", "invest", "finance"],
# Words that trigger specific tags
}

# -------------------------------------------------------------
# FILENAME FORMATS
# -------------------------------------------------------------
# Customize how your generated notes are named.
# Placeholders: {date}, {period}
INSIGHT_TITLE_FORMAT = "{date} {period} Insight.md"

# Placeholders: {date}
BRIEFING_TITLE_FORMAT = "{date} Morning Briefing.md"

# Placeholders: {date}, {time}, {subject}
RECAP_TITLE_FORMAT = "{date} ({time}) Study Recap — {subject}.md"

# Placeholders: {title}
TXT_TITLE_FORMAT = "{title}.md"
24 changes: 8 additions & 16 deletions generate_insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed

from config import MAX_FILE_SIZE, VAULT_PATH, DAYS_BACK, MAX_NOTE_CHARS, EXCLUDED_FOLDERS
from config import MAX_FILE_SIZE, VAULT_PATH, DAYS_BACK, MAX_NOTE_CHARS, EXCLUDED_FOLDERS, CANDIDATES, TEMPERATURES, INSIGHT_TITLE_FORMAT
from ai_backend import get_backend, call_ai, backend_label, run_startup_checks

SCRIPT_DIR = Path(__file__).parent
Expand All @@ -39,6 +39,8 @@
GROUNDING = PROMPTS["grounding"]
LENSES = PROMPTS["insights"]["lenses"]

TEMP_INSIGHTS = TEMPERATURES.get("insights")

def fill_prompt(template: str, **kwargs) -> str:
"""
Safe placeholder replacement that won't crash on literal { } in the template.
Expand Down Expand Up @@ -188,7 +190,7 @@ def run_lens(lens: dict, notes_block: str, period: str, backend: str) -> dict:
notes_block=notes_block,
)
print(f" Running lens: {lens['name']}...")
return {"name": lens["name"], "result": call_ai(prompt, backend)}
return {"name": lens["name"], "result": call_ai(prompt, backend, temperature=TEMP_INSIGHTS)}


def run_synthesis(lens_results: list[dict], period: str, state: str, backend: str) -> tuple:
Expand Down Expand Up @@ -224,7 +226,7 @@ def run_synthesis(lens_results: list[dict], period: str, state: str, backend: st
)

print(" Running final synthesis...")
raw = call_ai(prompt, backend)
raw = call_ai(prompt, backend, temperature=TEMP_INSIGHTS)
return extract_state_from_synthesis(raw)


Expand All @@ -238,17 +240,8 @@ def extract_tags(lens_results: list[dict], synthesis: str) -> list[str]:
base_tags = ["insights"]
text = synthesis.lower() + " ".join(r["result"].lower() for r in lens_results)

candidates = {
"productivity": ["productiv", "task", "goal", "work", "focus"],
"mood": ["mood", "emotion", "feel", "stress", "anxiet", "happy"],
"philosophy": ["meaning", "values", "purpose", "reflect", "life"],
"habits": ["habit", "routine", "pattern", "repeat", "daily"],
"health": ["health", "sleep", "exercise", "energy", "body"],
"projects": ["project", "build", "code", "ship", "launch", "develop"],
"relationships": ["friend", "family", "partner", "social", "connect"],
}

for tag, keywords in candidates.items():

for tag, keywords in CANDIDATES.items():
if any(kw in text for kw in keywords):
base_tags.append(tag)

Expand All @@ -267,14 +260,13 @@ def write_insight_note(lens_results: list[dict], synthesis: str, note_count: int

is_month = DAYS_BACK > 7
period_label = "Week" if DAYS_BACK <= 7 else "Monthly"
filename = f"{date_str} {period_label} Insight.md"
filename = INSIGHT_TITLE_FORMAT.format(date=date_str, period=period_label)

if is_month:
time_property = "month: " + now.strftime("%B %Y")
else:
time_property = "week: " + get_week_label()


INSIGHT_FOLDER.mkdir(parents=True, exist_ok=True)
filepath = INSIGHT_FOLDER / filename
tags = extract_tags(lens_results, synthesis)
Expand Down
15 changes: 7 additions & 8 deletions morning_briefing.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import json
import datetime

from config import EXCLUDED_FOLDERS, MAX_FILE_SIZE, VAULT_PATH, MAX_NOTE_CHARS
from config import EXCLUDED_FOLDERS, MAX_FILE_SIZE, VAULT_PATH, MAX_NOTE_CHARS, TEMPERATURES, BRIEFING_TITLE_FORMAT
from ai_backend import get_backend, call_ai, backend_label, run_startup_checks

SCRIPT_DIR = Path(__file__).parent
Expand All @@ -33,6 +33,8 @@

GROUNDING = PROMPTS["grounding"]

TEMP_BRIEFING = TEMPERATURES.get("briefing")

def fill_prompt(template: str, **kwargs) -> str:
"""
Safe placeholder replacement that won't crash on literal { } in the template.
Expand All @@ -46,8 +48,6 @@ def fill_prompt(template: str, **kwargs) -> str:
def collect_notes(days_back: float) -> list[dict]:
"""
Collect notes modified within the last `days_back` days.
Skips Briefings and Insights folders to avoid feeding
generated content back into the briefing.

Args:
days_back: How many days back to look. Use 0 for today only,
Expand Down Expand Up @@ -129,13 +129,12 @@ def generate_briefing(yesterday_notes: list[dict], today_notes: list[dict], back
today_block=today_block,
)

return call_ai(prompt, backend)
return call_ai(prompt, backend, temperature=TEMP_BRIEFING)

def write_briefing(content: str) -> str:
"""
Write the briefing to the Briefings folder in the vault.
Creates the folder if it doesn't exist.
Frontmatter is built line by line to avoid f-string issues.

Args:
content: The briefing Markdown content from generate_briefing().
Expand All @@ -146,7 +145,7 @@ def write_briefing(content: str) -> str:
BRIEFING_FOLDER.mkdir(parents=True, exist_ok=True)

date_str = datetime.datetime.now().strftime("%Y-%m-%d")
filename = f"{date_str} Morning Briefing.md"
filename = BRIEFING_TITLE_FORMAT.format(date=date_str)
filepath = BRIEFING_FOLDER / filename

fm_lines = [
Expand Down Expand Up @@ -186,7 +185,7 @@ def main():
print(f" found {total} note(s) modified in the last 24h\n")

if not yesterday_notes and not today_notes:
print(" no recent notes found — briefing will be minimal\n")
print(" no recent notes found\n")

print(" generating briefing...")
content = generate_briefing(yesterday_notes, today_notes, backend)
Expand All @@ -196,4 +195,4 @@ def main():


if __name__ == "__main__":
main()
main()
13 changes: 7 additions & 6 deletions study_recap.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import datetime
import re

from config import EXCLUDED_FOLDERS, MAX_FILE_SIZE, VAULT_PATH, HOURS_BACK, MAX_NOTE_CHARS
from config import EXCLUDED_FOLDERS, MAX_FILE_SIZE, VAULT_PATH, HOURS_BACK, MAX_NOTE_CHARS, TEMPERATURES, RECAP_TITLE_FORMAT
from ai_backend import get_backend, call_ai, backend_label, run_startup_checks

SCRIPT_DIR = Path(__file__).parent
Expand All @@ -30,6 +30,8 @@

GROUNDING = PROMPTS["grounding"]

TEMP_RECAP = TEMPERATURES.get("recap")

R = "\033[0m"
DIM = "\033[2m"
BOLD = "\033[1m"
Expand Down Expand Up @@ -98,14 +100,13 @@ def find_recent_notes(hours: int) -> list[dict]:
def index_all_notes() -> dict[str, str]:
"""
Build an index of all notes in the vault for connection finding.
Only reads the first 500 chars of each note to keep memory usage low.
"""
notes = {}
for path_obj in VAULT_PATH.rglob("*.md"):
try:
if path_obj.stat().st_size > MAX_FILE_SIZE:
continue
content = path_obj.read_text(encoding="utf-8", errors="ignore")[:500]
content = path_obj.read_text(encoding="utf-8", errors="ignore")[:2000]
notes[path_obj.stem] = content
except Exception:
continue
Expand Down Expand Up @@ -192,7 +193,7 @@ def generate_recap(notes_data: list[dict], all_notes: dict, backend: str) -> str
notes_block=notes_block,
all_titles=all_titles,
)
return call_ai(prompt, backend, timeout=600)
return call_ai(prompt, backend, temperature=TEMP_RECAP)


def write_recap(content: str, note_names: list[str]) -> str:
Expand All @@ -210,8 +211,8 @@ def write_recap(content: str, note_names: list[str]) -> str:
else:
subject = f"{stems[0]}, {stems[1]} (+{len(stems)-2})"

filename = f"{date_str} {time_str} Recap - {subject}.md"
filename = re.sub(r'[\\/*?:"<>|]', "", filename)
safe_subject = re.sub(r'[\\/*?:"<>|]', "", subject)
filename = RECAP_TITLE_FORMAT.format(date=date_str, time=time_str, subject=safe_subject)
filepath = RECAP_FOLDER / filename

tags_lines = [
Expand Down
14 changes: 8 additions & 6 deletions txt_to_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# =============================================================
# Converts any text file into one or more structured Obsidian
# notes. Works with conversations, meeting notes, articles,
# braindumps — anything you can paste into a .txt file.
# anything you can paste into a .txt file.
#
# Two-pass process:
# Pass 1: AI reads the text and decides how many notes to create,
Expand All @@ -25,7 +25,7 @@
import datetime
from pathlib import Path

from config import MAX_FILE_SIZE, VAULT_PATH
from config import MAX_FILE_SIZE, VAULT_PATH, TEMPERATURES, TXT_TITLE_FORMAT
from ai_backend import get_backend, call_ai, backend_label, run_startup_checks

SCRIPT_DIR = Path(__file__).parent
Expand All @@ -48,6 +48,8 @@
GREEN = "\033[32m"
RED = "\033[31m"

TEMP_TXT = TEMPERATURES.get("txt")

def format_obsidian_tag(text: str) -> str:
text = text.lower().replace(" ", "-")
return re.sub(r'[^a-z0-9_-]', '', text)
Expand Down Expand Up @@ -147,7 +149,7 @@ def plan_notes(text, instructions, existing_tags, existing_titles, backend) -> l
titles_hint=titles_hint,
)

raw = call_ai(prompt, backend)
raw = call_ai(prompt, backend, temperature=TEMP_TXT)
raw = re.sub(r"```(?:json)?", "", raw).strip()

match = re.search(r"\[.*\]", raw, re.DOTALL)
Expand Down Expand Up @@ -182,15 +184,15 @@ def write_note_content(text, note_plan, all_titles_in_batch, instructions, exist
related_links=related_links,
tags_hint=tags_hint,
)
return call_ai(prompt, backend, timeout=300)
return call_ai(prompt, backend, temperature=TEMP_TXT)


def write_file(note_plan, body, folder_path, date_str) -> str:
"""Write a single note to disk with Obsidian frontmatter."""
title = note_plan.get("title", "Untitled")
tags = [format_obsidian_tag(t) for t in note_plan.get("tags", [])]
safe_title = re.sub(r'[\\/*?:"<>|]', "", title)
filename = f"{safe_title}.md"
filename = TXT_TITLE_FORMAT.format(title=safe_title)

filepath = Path(folder_path) / filename

Expand Down Expand Up @@ -280,4 +282,4 @@ def main():


if __name__ == "__main__":
main()
main()
Loading