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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ User-facing changes to ConductLoop (the desktop app and its local server), newes

## Unreleased

- **The dark theme works on the dashboard.** The ◐ toggle added the `dark` class but the dashboard stayed white: its colour aliases were resolved on `:root` with the light palette. They are now declared on `body` too.
- **Better answers without an AI key.** Words of one family match ("decide" and "decision", "pricing" and "price"), a line under a matching heading counts it and shows it ("Decision (2026-10-21) — Keep both plans…"), a note's title adds a little, and the note with the best line comes first. Asked "What did we decide about pricing?", it now quotes the decision instead of a daily note that mentions pricing.
- **Note actions in your language.** Key points, Summarize, Action items and Expand answered in Spanish for everyone; they now follow the browser's language (Spanish for es-*, English otherwise), and the API defaults to English. The Translate buttons keep their own language.
- Search results on the notes home show the words around the match instead of a few words.
- **Copy that is true on every system.** The welcome page said the AI key "is stored in the Keychain of this Mac", also on Linux and Windows (seen in the first Linux and Windows smoke tests). It now says the system keychain (the Keychain on a Mac) or an encrypted file. The same for the other "this Mac" / "este Mac" in the interface (Connectors, Learning, the feedback buttons, Servers, Media): "this computer" / "este ordenador". Nothing changes on the Mac except the wording.
- CI only: the "Windows bundles" workflow now runs `scripts/windows-smoke.ps1` on the NSIS installer: it installs it silently, starts the app as a new user, and checks the server, that `/api/platform` says Windows with the in-app browser and code execution off, the first run and welcome page, the window (with a screenshot), that the app and its server are still running 20 s later, that closing the window stops the server, and that **uninstalling keeps the user's data** (it lives in `%LOCALAPPDATA%\ConductLoop\data`, inside the install folder). Still informational (a runner has no real desktop session), but each failed check is now an error annotation on the run page, and the screenshot and log are kept for 3 days.
- CI only: the Linux bundles workflow now **starts the AppImage and the .deb** it builds, on Ubuntu 22.04 (the build image) and on ubuntu-latest, on a virtual display with an empty HOME and no keyring. `scripts/linux-smoke.sh` checks that the local server answers, that `/api/platform` says Linux with the in-app browser off, that it is a first run with the welcome page, that a ConductLoop window is on screen, and that closing the app stops the server; screenshots and logs are kept for 3 days. Until now the Linux packages were only built, never run. The script also works by hand on a Linux machine.
Expand Down
58 changes: 43 additions & 15 deletions ops_dashboard/brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,9 +423,21 @@ def ask(self, system: str, messages: list[dict], **kwargs) -> str:
)


_SUFFIXES = ("ations", "ation", "ings", "ing", "ions", "ion", "ers", "er", "ed", "es", "s", "e")


def _key(word: str) -> str:
"""A rough stem to match words of one family: "decide", "decided" and
"decision" give "deci"; "pricing", "price" and "prices" give "pric"."""
for suffix in _SUFFIXES:
if word.endswith(suffix) and len(word) - len(suffix) >= 4:
word = word[: -len(suffix)]
break
return word[:4]


def _terms(text: str) -> set[str]:
words = re.findall(r"[^\W_]{3,}", text.lower())
return {w[:-1] if len(w) > 4 and w.endswith("s") else w for w in words if w not in _STOPWORDS}
return {_key(w) for w in re.findall(r"[^\W_]{3,}", text.lower()) if w not in _STOPWORDS}


def _matching_lines(system: str, question: str, *, docs: int = 3, per_doc: int = 2) -> list[str]:
Expand Down Expand Up @@ -461,30 +473,46 @@ def _matching_lines(system: str, question: str, *, docs: int = 3, per_doc: int =
if body and body[0].strip() == "---":
end = next((k for k in range(1, len(body)) if body[k].strip() == "---"), None)
body = body[end + 1:] if end is not None else body
# Each line counts the question's words it has, plus those of the
# heading it sits under ("## Decision" then "Keep both plans…"; half for
# the lines after the first), plus a quarter point for each one in the
# note's title. Headings are context, not answers: a line is only a
# candidate if it or its heading matches.
scored = []
heading, heading_terms, under = "", set(), 0
for k, raw in enumerate(body):
text = re.sub(r"^#+\s*", "", raw.strip())
stripped = raw.strip()
text = re.sub(r"^#+\s*", "", stripped)
if not text or text.startswith(("<<<", "url: ")):
continue
if text.startswith("title: "):
b["title"] = text[7:].strip() or b["title"]
continue
if text.lower() == b["title"].lower(): # the note's own heading
if stripped.startswith("#"):
same = text.lower() == b["title"].lower() # the note's own heading
heading, heading_terms = ("", set()) if same else (text, _terms(text) & terms)
under = 0
continue
under += 1
own = _terms(text) & terms
context = heading_terms - own
if not own and not context:
continue
score = sum(1 for t in terms if t in text.lower())
if score:
scored.append((score, k, text if len(text) <= 240 else text[:239] + "…"))
title = (_terms(b["title"]) & terms) - own - context
shown = f"{heading} — {text}" if context else text
score = len(own) + len(context) * (1 if under == 1 else 0.5) + 0.25 * len(title)
scored.append((score, k, shown if len(shown) <= 240 else shown[:239] + "…"))
b["scored"] = scored
# A line has to share at least half as many words with the question as the
# best line does: one common word ("need") is not a match next to four.
top = max((s[0] for b in blocks for s in b["scored"]), default=0)
floor = max(1, (top + 1) // 2)
for b in blocks:
scored = [s for s in b["scored"] if s[0] >= floor]
b["best"] = max((x[0] for x in scored), default=0)
# A line has to score at least half as much as the best line: one common
# word ("need") is not a match next to four. Notes go best line first.
top = max((b["best"] for b in blocks), default=0)
for b in sorted(blocks, key=lambda b: (-b["best"], int(b["n"]))):
scored = [x for x in b["scored"] if x[0] >= top / 2]
if not scored:
continue
best = sorted(sorted(scored, key=lambda s: (-s[0], s[1]))[:per_doc], key=lambda s: s[1])
out.append(f"**{b['title']}** [{b['n']}]\n" + "\n".join(f"> {s[2]}" for s in best))
best = sorted(sorted(scored, key=lambda x: (-x[0], x[1]))[:per_doc], key=lambda x: x[1])
out.append(f"**{b['title']}** [{b['n']}]\n" + "\n".join(f"> {x[2]}" for x in best))
if len(out) >= docs:
break
return out
Expand Down
4 changes: 2 additions & 2 deletions ops_dashboard/indexers/note_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
POST /api/notes/<id>/ai-action {action: 'summarize'|'expand'|'translate'|'key_points'|'action_items',
target_lang?: 'es'|'en'|...}

For 'translate', target_lang defaults to 'es'.
For 'translate', target_lang defaults to 'en'.
The result is returned as JSON and can be applied to a new note or
appended to the existing one (caller's choice).

Expand Down Expand Up @@ -54,7 +54,7 @@
),
}

DEFAULT_LANG = "es"
DEFAULT_LANG = "en"


def get_note_content(doc_id: int) -> tuple[dict | None, str | None]:
Expand Down
2 changes: 1 addition & 1 deletion ops_dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4873,7 +4873,7 @@ def _notes_ai_action(self, doc_id: int) -> None:
try: data = self._read_json()
except Exception: data = {}
action = data.get("action") or ""
target_lang = data.get("lang") or data.get("target_lang") or "es"
target_lang = data.get("lang") or data.get("target_lang") or "en"
provider = data.get("provider") or None
from .indexers import note_ai
result = note_ai.run_action(doc_id, action, target_lang=target_lang, provider_name=provider)
Expand Down
12 changes: 7 additions & 5 deletions ops_dashboard/web/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@

/* Design system (2026-10-23). The legacy token names below are aliases of
the shared system in /theme.css, so this page adopts one palette without
touching the component CSS. New code should use the --cl-* names. */
:root {
touching the component CSS. New code should use the --cl-* names.
Declared on body too: an alias is resolved where it is declared, so on
:root alone it kept the light palette when body.dark changed --cl-*
(the dark toggle did nothing on the dashboard). */
:root, body {
--bg: var(--cl-bg);
--bg-soft: var(--cl-bg-soft);
--bg-2: var(--cl-bg-3);
Expand All @@ -27,9 +30,8 @@
--red: var(--cl-danger);
--yellow: var(--cl-warn);
}
/* Dark mode now comes from the shared system (/theme.css -> body.dark),
so there is one palette instead of two that drift apart. */
body.dark { }
/* Dark mode comes from the shared system (/theme.css -> body.dark), so
there is one palette instead of two that drift apart. */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
Expand Down
3 changes: 2 additions & 1 deletion ops_dashboard/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,8 @@ function renderNotesHome() {
var li = el('li');
var a = link('/note/' + encodeURIComponent(r.id), r.title || String(r.path || '').split('/').pop());
li.appendChild(a);
var excerpt = String(r.excerpt || '').replace(/\s+/g, ' ').trim();
// content_snip has the words around the match; excerpt is a few words only.
var excerpt = String(r.content_snip || r.excerpt || '').replace(/\s+/g, ' ').trim();
if (excerpt) li.appendChild(el('span', 'nh-excerpt', excerpt.slice(0, 160)));
list.appendChild(li);
});
Expand Down
28 changes: 18 additions & 10 deletions ops_dashboard/web/note.html
Original file line number Diff line number Diff line change
Expand Up @@ -73,20 +73,20 @@ <h1 id="title">Loading…</h1>
<div class="sidebar-content">
<div id="ai-pane">
<div id="ai-actions">
<button class="ai-action" data-action="key_points" data-lang="es">
<div class="title">🔑 Key points <span class="lang-pill">ES</span></div>
<button class="ai-action" data-action="key_points" data-lang="auto">
<div class="title">🔑 Key points <span class="lang-pill"></span></div>
<div class="desc">Extract 3-7 main takeaways</div>
</button>
<button class="ai-action" data-action="summarize" data-lang="es">
<div class="title">📝 Summarize <span class="lang-pill">ES</span></div>
<button class="ai-action" data-action="summarize" data-lang="auto">
<div class="title">📝 Summarize <span class="lang-pill"></span></div>
<div class="desc">3-5 bullet summary</div>
</button>
<button class="ai-action" data-action="action_items" data-lang="es">
<div class="title">☑️ Action items <span class="lang-pill">ES</span></div>
<button class="ai-action" data-action="action_items" data-lang="auto">
<div class="title">☑️ Action items <span class="lang-pill"></span></div>
<div class="desc">Extract TODOs / next steps</div>
</button>
<button class="ai-action" data-action="expand" data-lang="es">
<div class="title">✍️ Expand <span class="lang-pill">ES</span></div>
<button class="ai-action" data-action="expand" data-lang="auto">
<div class="title">✍️ Expand <span class="lang-pill"></span></div>
<div class="desc">More detail + structure</div>
</button>
<button class="ai-action" data-action="translate" data-lang="es">
Expand Down Expand Up @@ -236,11 +236,19 @@ <h3 style="font-size:13px;color:var(--muted);text-transform:uppercase;letter-spa
'</div>';
}

// AI actions
// AI actions. Key points, Summarize, Action items and Expand answer in the
// language of this browser (Spanish for es-*, English otherwise); the
// Translate buttons name their own. They were all fixed to Spanish.
const UI_LANG = /^es\b/i.test(navigator.language || '') ? 'es' : 'en';
document.querySelectorAll('.ai-action[data-lang="auto"]').forEach(b => {
b.dataset.lang = UI_LANG;
const pill = b.querySelector('.lang-pill');
if (pill) pill.textContent = UI_LANG.toUpperCase();
});
document.querySelectorAll('.ai-action').forEach(b => {
b.addEventListener('click', async () => {
const action = b.dataset.action;
const lang = b.dataset.lang || 'es';
const lang = b.dataset.lang || UI_LANG;
$('ai-actions').style.display = 'block';
document.querySelectorAll('.ai-action').forEach(x => x.disabled = true);
$('ai-result').innerHTML = '<div class="ai-empty">Thinking (' + action + ', ' + lang + ')…</div>';
Expand Down
30 changes: 30 additions & 0 deletions tests/test_brain_no_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,33 @@ def test_team_chat_context_block_is_read_too():
system = team_chat.agent_system({"name": "CFO", "title": "Chief Financial Officer"}, context, "")
out = _ask(system, "mortgage bank answer")
assert "[1]\n> Called the bank" in out


PRICING = {
"source": "obsidian", "path": "/v/Projects/Pricing experiment.md", "title": "Pricing experiment",
"content": "# Pricing experiment\nQuestion: does a yearly plan beat monthly?\n\n## Decision (2026-10-21)\n"
"Keep both plans and make yearly the default. Revisit the price after the launch.\n"
"Discussed in [[Design review]].",
}
SUNDAY = {
"source": "obsidian", "path": "/v/Daily/2026-10-26.md", "title": "2026-10-26",
"content": "# 2026-10-26\nSketched the launch post: problem, idea, what's new, pricing.",
}


def test_words_of_one_family_match():
from ops_dashboard.brain import _terms
assert _terms("decide") == _terms("decision") == _terms("decided") == {"deci"}
assert _terms("pricing") == _terms("price") == _terms("prices") == {"pric"}


def test_a_line_takes_the_words_of_its_heading_and_says_so():
out = _ask(_system([SUNDAY, PRICING]), "What did we decide about pricing?")
assert "> Decision (2026-10-21) — Keep both plans and make yearly the default." in out
assert "Discussed in" not in out # the heading counts less for the lines after the first
assert "> Question:" not in out # the title alone does not make a line an answer


def test_the_note_with_the_best_line_comes_first():
out = _ask(_system([SUNDAY, PRICING]), "What did we decide about pricing?")
assert out.index("**Pricing experiment** [2]") < out.index("**2026-10-26** [1]")
15 changes: 15 additions & 0 deletions tests/test_notes_home_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,18 @@ def test_a_question_from_the_url_is_put_in_the_box_never_sent():
block = src[i:src.index("\n", src.index("if (ask", i))]
assert "$('input').value = ask" in block
assert "send(" not in block


def test_note_ai_actions_answer_in_the_browsers_language_not_always_spanish():
html = (WEB / "note.html").read_text()
for action in ("key_points", "summarize", "action_items", "expand"):
assert f'data-action="{action}" data-lang="auto"' in html, action
assert "const UI_LANG = /^es\\b/i.test(navigator.language || '') ? 'es' : 'en';" in html
assert "b.dataset.lang || UI_LANG" in html
# The Translate buttons keep their own target.
assert 'data-action="translate" data-lang="es"' in html and 'data-action="translate" data-lang="en"' in html


def test_search_results_show_the_words_around_the_match():
body = _function((WEB / "app.js").read_text(), "renderNotesHome")
assert "r.content_snip || r.excerpt" in body
Loading