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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ All releases, with checksums: [conductloop-releases](https://github.com/EVMCOD/c
- **Backups** of the local database, daily and before every upgrade, restorable from Setup.
- **It learns from your 👍/👎**: proposed rules that you approve, all stored on your computer.

The screenshots use a fictional vault. Dark theme: ◐ in the corner.
| Ask the team | The CEO's answer |
|---|---|
| ![Team mode: the CEO hands the question to the Head of Product, CTO, CMO and CFO; each card shows its answer and time](docs/screenshots/09-team-cards.png) | ![The CEO's final answer: a numbered plan with sources](docs/screenshots/08-team-answer.png) |

The screenshots use a fictional vault; the team ones ran on a real model (MiniMax-M3). Dark theme: ◐ in the corner.

---

Expand Down
Binary file added docs/screenshots/08-team-answer.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/09-team-cards.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/10-brain-answer.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion ops_dashboard/brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ def _local_endpoint(base_url: str | None) -> bool:
- A cross-source TODO/pendiente list.

You are direct, concise, and opinionated. Match the user's language. When you
cite a fact, append the source like [obsidian:filename.md] or [repo:name].
cite a fact from the context excerpts, append the excerpt's number, like [1].
Don't make up information. If you don't know, say so.

Today's date: {today}
Expand Down
4 changes: 3 additions & 1 deletion ops_dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2622,7 +2622,9 @@ def _brain_ask_tools(self) -> None:
today = time.strftime("%Y-%m-%d")
prefs = db.memory_list()
pref_str = "\n".join(f"- {p['key']}: {p['value']}" for p in prefs) or "(none yet)"
system = brain.SYSTEM_PROMPT_BASE.format(today=today, preferences=pref_str)
system = brain.SYSTEM_PROMPT_BASE.format(today=today, preferences=pref_str) + (
"\n\nThere are no numbered excerpts here: cite facts from tool results by "
"source, like [obsidian:filename.md] or [repo:name].")
messages = [{"role": "user", "content": q}]
tool_log = []
def on_call(name, args, result):
Expand Down
47 changes: 40 additions & 7 deletions ops_dashboard/web/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,43 @@
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + text + '</a>';
});
s = s.replace(/\[(\d{1,2})\]/g, '<span class="cite">$1</span>');
// Some models cite by source ("[obsidian:Pricing.md]") instead of [N]: show the name as a chip.
s = s.replace(/\[(obsidian|repo|memory|web|voice|mcp):([^\]\n]{1,120})\]/g, function (_m, kind, name) {
var label = name.split('/').pop().replace(/\.md$/i, '');
return '<span class="cite" title="' + kind + ':' + name + '">' + label + '</span>';
});
return s;
}
var LIST = /^(\s*)([-*•]|\d+[.)])\s+(.*)$/;
// One list starting at lines[i]. Deeper-indented items nest inside the item
// above, and an ordered list keeps its first number, so "1. … - note … 2. …"
// doesn't restart at 1.
function list(lines, i, indent) {
var first = LIST.exec(lines[i]), ordered = /\d/.test(first[2]), items = [];
while (i < lines.length) {
if (!lines[i].trim() && i + 1 < lines.length) {
var next = LIST.exec(lines[i + 1]);
if (next && next[1].length >= indent) { i++; continue; }
break;
}
var m = LIST.exec(lines[i]);
if (!m || m[1].length < indent) break;
if (m[1].length > indent && items.length) {
var sub = list(lines, i, m[1].length);
items[items.length - 1] += sub.html;
i = sub.i;
continue;
}
if (/\d/.test(m[2]) !== ordered) break;
items.push('<li>' + inline(m[3]));
i++;
}
var tag = ordered ? 'ol' : 'ul', n = ordered ? parseInt(first[2], 10) : 1;
return {
html: '<' + tag + (n > 1 ? ' start="' + n + '"' : '') + '>' + items.join('</li>') + '</li></' + tag + '>',
i: i
};
}
function md(src) {
var out = [], lines = String(src || '').replace(/\r/g, '').split('\n'), i = 0, para = [];
function flush() { if (para.length) { out.push('<p>' + para.map(inline).join('<br>') + '</p>'); para = []; } }
Expand All @@ -76,14 +111,12 @@
}
var h = /^(#{1,3})\s+(.*)$/.exec(line);
if (h) { flush(); out.push('<h' + h[1].length + '>' + inline(h[2]) + '</h' + h[1].length + '>'); i++; continue; }
if (/^\s*([-*•])\s+/.test(line) || /^\s*\d+[.)]\s+/.test(line)) {
var item = LIST.exec(line);
if (item) {
flush();
var ordered = /^\s*\d+[.)]\s+/.test(line), items = [];
while (i < lines.length && (ordered ? /^\s*\d+[.)]\s+/ : /^\s*[-*•]\s+/).test(lines[i])) {
items.push('<li>' + inline(lines[i].replace(/^\s*([-*•]|\d+[.)])\s+/, '')) + '</li>');
i++;
}
out.push((ordered ? '<ol>' : '<ul>') + items.join('') + (ordered ? '</ol>' : '</ul>'));
var r = list(lines, i, item[1].length);
out.push(r.html);
i = r.i;
continue;
}
if (/^>\s?/.test(line)) { flush(); out.push('<blockquote>' + inline(line.replace(/^>\s?/, '')) + '</blockquote>'); i++; continue; }
Expand Down
55 changes: 55 additions & 0 deletions tests/test_chat_markdown_js.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Unit test for the markdown renderer in web/chat.js (esc → inline → list →
md). Pulls those functions out of the IIFE and runs them under Node; skipped
when Node isn't installed."""
from __future__ import annotations

import json
import re
import shutil
import subprocess
from pathlib import Path

import pytest

CHAT = Path(__file__).resolve().parent.parent / "ops_dashboard" / "web" / "chat.js"
NODE = shutil.which("node")

CASES = {
# A bullet between numbered items used to close the <ol> and restart at 1.
"split_ordered": "1. Ship it\n- note\n2. Tell people",
"nested": "1. Ship it\n - build\n - test\n2. Tell people",
"loose": "1. One\n\n2. Two",
"source_cite": "Price is 12 dollars [obsidian:Pricing experiment.md] and [repo:tidepool].",
"number_cite": "Fact [2].",
"escaped": "[obsidian:<img src=x onerror=alert(1)>.md]",
}


def _render() -> dict:
src = CHAT.read_text()
start = src.index(" function esc(s)")
helpers = src[start:src.index(" function tint(", start)]
md_start = src.index(" function inline(s)")
md_end = src.index(" // Re-render at most once per frame")
script = helpers + src[md_start:md_end] + (
"\nconst cases = JSON.parse(process.argv[1]); const out = {};"
"\nfor (const k in cases) out[k] = md(cases[k]);"
"\nprocess.stdout.write(JSON.stringify(out));")
run = subprocess.run([NODE, "-e", script, json.dumps(CASES)], capture_output=True, text=True, timeout=30)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)


@pytest.mark.skipif(not NODE, reason="node not installed")
def test_chat_markdown_lists_and_citations():
out = _render()
assert out["split_ordered"] == '<ol><li>Ship it</li></ol><ul><li>note</li></ul><ol start="2"><li>Tell people</li></ol>'
assert out["nested"] == "<ol><li>Ship it<ul><li>build</li><li>test</li></ul></li><li>Tell people</li></ol>"
assert out["loose"] == "<ol><li>One</li><li>Two</li></ol>"
assert '<span class="cite" title="obsidian:Pricing experiment.md">Pricing experiment</span>' in out["source_cite"]
assert '<span class="cite" title="repo:tidepool">tidepool</span>' in out["source_cite"]
assert out["number_cite"] == '<p>Fact <span class="cite">2</span>.</p>'
# Escaped before formatting: the only tags are ours, the name stays text.
assert re.findall(r"<(\w+)", out["escaped"]) == ["p", "span"]
assert out["escaped"] == ('<p><span class="cite" title="obsidian:&lt;img src=x onerror=alert(1)&gt;.md">'
"&lt;img src=x onerror=alert(1)&gt;</span></p>")
Loading