Add Discord-native hybrid front-end for Jarvis (bot + bridge)
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled

Transform isair/jarvis into a Discord-controlled voice assistant running on
the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact.

- bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral),
  voice channel join + voice receive/playback, pluggable VNC screen broadcast
  (selfbot live / noVNC / screenshot)
- bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS
  behind a thin localhost HTTP API
- .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite,
  docs/language-comparison.md and docs/vnc-xfce-setup.md

Language decision: hybrid (Python brain + Node/bun Discord layer) because
Discord blocks bot video; native screen broadcast only works via a Node
selfbot library.
This commit is contained in:
javis-bot
2026-06-09 14:51:05 +09:00
parent a5bf8d1826
commit c4abf63f38
308 changed files with 94135 additions and 1 deletions

608
tests/test_graph_memory.py Normal file
View File

@@ -0,0 +1,608 @@
"""Tests for the node graph memory system (v2)."""
import os
import tempfile
from datetime import datetime, timezone
import pytest
from src.jarvis.memory.graph import (
GraphMemoryStore,
MemoryNode,
_estimate_tokens,
SPLIT_THRESHOLD,
MERGE_THRESHOLD,
FIXED_BRANCHES,
)
# Number of fixed top-level branches seeded under root on bootstrap
# (User / Directives / World). See graph.py FIXED_BRANCHES.
SEEDED = len(FIXED_BRANCHES)
BOOTSTRAP_NODE_COUNT = SEEDED + 1 # seeded branches + root
@pytest.fixture
def store(tmp_path):
"""Create a fresh GraphMemoryStore with a temporary database."""
db_path = str(tmp_path / "test_graph.db")
s = GraphMemoryStore(db_path)
yield s
s.close()
@pytest.mark.unit
class TestEstimateTokens:
"""Token estimation heuristic tests."""
def test_empty_string(self):
assert _estimate_tokens("") == 0
def test_short_text(self):
assert _estimate_tokens("hello world") == 2 # 11 chars / 4
def test_longer_text(self):
text = "a" * 400
assert _estimate_tokens(text) == 100
@pytest.mark.unit
class TestMemoryNodeModel:
"""MemoryNode dataclass tests."""
def test_to_dict_roundtrip(self):
node = MemoryNode(
id="abc",
name="Test",
description="A test node",
data="some data",
)
d = node.to_dict()
assert d["id"] == "abc"
assert d["name"] == "Test"
assert d["description"] == "A test node"
assert d["data"] == "some data"
assert d["parent_id"] is None
assert d["access_count"] == 0
def test_default_timestamps_populated(self):
node = MemoryNode(id="x", name="X", description="x")
assert node.created_at is not None
assert node.last_accessed is not None
@pytest.mark.unit
class TestGraphMemoryStoreBootstrap:
"""Schema initialisation and root node creation."""
def test_root_node_created_on_init(self, store):
root = store.get_root()
assert root is not None
assert root.id == "root"
assert root.parent_id is None
def test_root_not_duplicated(self, store):
"""Re-initialising must not create a second root."""
store._ensure_root()
nodes = store.get_all_nodes()
root_nodes = [n for n in nodes if n.parent_id is None]
assert len(root_nodes) == 1
def test_node_count_starts_with_seeded_branches(self, store):
# root + fixed branches (User / Directives / World)
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT
def test_total_tokens_zero_for_empty_graph(self, store):
assert store.get_total_tokens() == 0
def test_total_tokens_reflects_stored_data(self, store):
store.create_node(
name="Facts",
description="Things I know",
data="The sky is blue. Water boils at 100C.",
parent_id="root",
)
assert store.get_total_tokens() > 0
def test_total_tokens_stays_zero_when_root_only_and_empty(self, store):
# Even after touching/updating the root without data, tokens remain zero.
root = store.get_root()
store.update_node(root.id, description="updated description")
assert store.get_total_tokens() == 0
@pytest.mark.unit
class TestMigrateLegacyShape:
"""Startup wipe when the on-disk graph predates the User/Directives/World taxonomy."""
def test_no_wipe_on_fresh_graph(self, store):
"""Freshly seeded graph (root + 3 branches, no data) is conforming."""
assert store.migrate_legacy_shape() is False
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT
def test_no_wipe_when_only_descendants_of_fixed_branches(self, store):
"""Children grown under User/Directives/World are fine — the shape
check only looks at direct root children."""
store.create_node(
name="Identity", description="who the user is",
data="User's name is Baris.", parent_id="user",
)
assert store.migrate_legacy_shape() is False
# Content preserved
assert any(
n.name == "Identity" for n in store.get_all_nodes()
)
def test_wipes_when_root_has_rogue_child(self, store):
"""Pre-taxonomy nodes sitting directly under root trigger a wipe."""
store.create_node(
name="People", description="pre-taxonomy category",
data="Alice is a friend.", parent_id="root",
)
assert store.migrate_legacy_shape() is True
# After wipe: only root + seeded branches, no rogue child
names = {n.name for n in store.get_all_nodes()}
assert "People" not in names
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT
def test_wipes_when_root_itself_has_data(self, store):
"""Cold-start facts appended to root before the taxonomy existed
also count as non-conforming."""
store.conn.execute(
"UPDATE memory_nodes SET data = ? WHERE id = 'root'",
("Some pre-taxonomy fact on root.",),
)
store.conn.commit()
assert store.migrate_legacy_shape() is True
root = store.get_root()
assert root.data == ""
def test_reseeds_fixed_branches_after_wipe(self, store):
"""After a wipe the three fixed branches are present again."""
store.create_node(
name="Rogue", description="x", data="y", parent_id="root",
)
assert store.migrate_legacy_shape() is True
children = store.get_children("root")
child_ids = {c.id for c in children}
assert child_ids == {b[0] for b in FIXED_BRANCHES}
@pytest.mark.unit
class TestNodeCRUD:
"""Create, read, update, delete operations."""
def test_create_and_get_node(self, store):
node = store.create_node(
name="People",
description="People I know",
data="Alice is a friend.",
parent_id="root",
)
assert node.id is not None
assert node.name == "People"
assert node.parent_id == "root"
assert node.data_token_count > 0
fetched = store.get_node(node.id)
assert fetched is not None
assert fetched.name == "People"
def test_create_node_without_data(self, store):
node = store.create_node(name="Empty", description="No data")
assert node.data == ""
assert node.data_token_count == 0
def test_get_nonexistent_node_returns_none(self, store):
assert store.get_node("does-not-exist") is None
def test_update_node_name(self, store):
node = store.create_node(name="Old", description="desc", parent_id="root")
updated = store.update_node(node.id, name="New")
assert updated is not None
assert updated.name == "New"
refetched = store.get_node(node.id)
assert refetched.name == "New"
def test_update_node_data_recalculates_tokens(self, store):
node = store.create_node(name="N", description="d", data="short", parent_id="root")
original_tokens = node.data_token_count
updated = store.update_node(node.id, data="a" * 200)
assert updated.data_token_count == 50
assert updated.data_token_count != original_tokens
def test_update_nonexistent_returns_none(self, store):
assert store.update_node("nope", name="X") is None
def test_delete_node(self, store):
node = store.create_node(name="Temp", description="d", parent_id="root")
assert store.delete_node(node.id) is True
assert store.get_node(node.id) is None
def test_delete_nonexistent_returns_false(self, store):
assert store.delete_node("nope") is False
def test_cannot_delete_root(self, store):
assert store.delete_node("root") is False
assert store.get_root() is not None
def test_cannot_delete_fixed_branches(self, store):
"""The seeded preset branches (user / directives / world) are
non-deletable per graph.spec.md."""
for branch_id, _name, _desc in FIXED_BRANCHES:
assert store.delete_node(branch_id) is False, (
f"Fixed branch {branch_id!r} must not be deletable"
)
assert store.get_node(branch_id) is not None
@pytest.mark.unit
class TestNodeRelationships:
"""Parent-child relationships and tree queries."""
def test_get_children(self, store):
a = store.create_node(name="A", description="a", parent_id="root")
b = store.create_node(name="B", description="b", parent_id="root")
c = store.create_node(name="C", description="c", parent_id=a.id)
root_children = store.get_children("root")
# 2 test nodes + SEEDED fixed branches
assert len(root_children) == 2 + SEEDED
child_ids = {c.id for c in root_children}
assert a.id in child_ids
assert b.id in child_ids
a_children = store.get_children(a.id)
assert len(a_children) == 1
assert a_children[0].id == c.id
def test_get_children_empty(self, store):
node = store.create_node(name="Leaf", description="d", parent_id="root")
assert store.get_children(node.id) == []
def test_get_ancestors(self, store):
a = store.create_node(name="A", description="a", parent_id="root")
b = store.create_node(name="B", description="b", parent_id=a.id)
c = store.create_node(name="C", description="c", parent_id=b.id)
ancestors = store.get_ancestors(c.id)
assert len(ancestors) == 4 # root -> A -> B -> C
assert ancestors[0].id == "root"
assert ancestors[1].id == a.id
assert ancestors[2].id == b.id
assert ancestors[3].id == c.id
def test_get_ancestors_of_root(self, store):
ancestors = store.get_ancestors("root")
assert len(ancestors) == 1
assert ancestors[0].id == "root"
def test_get_subtree(self, store):
a = store.create_node(name="A", description="a", parent_id="root")
b = store.create_node(name="B", description="b", parent_id=a.id)
tree = store.get_subtree("root", max_depth=3)
assert tree["node"]["id"] == "root"
assert len(tree["children"]) == 1 + SEEDED
a_child = next(c for c in tree["children"] if c["node"]["id"] == a.id)
assert len(a_child["children"]) == 1
assert a_child["children"][0]["node"]["id"] == b.id
def test_get_subtree_depth_limit(self, store):
a = store.create_node(name="A", description="a", parent_id="root")
b = store.create_node(name="B", description="b", parent_id=a.id)
tree = store.get_subtree("root", max_depth=1)
# root (depth 0) -> A + seeded branches (depth 1), but B (depth 2) should not appear
assert len(tree["children"]) == 1 + SEEDED
for child in tree["children"]:
assert child["children"] == []
@pytest.mark.unit
class TestAccessTracking:
"""Touch, recent nodes, and top nodes."""
def test_touch_increments_access_count(self, store):
node = store.create_node(name="N", description="d", parent_id="root")
assert node.access_count == 0
store.touch_node(node.id)
store.touch_node(node.id)
store.touch_node(node.id)
updated = store.get_node(node.id)
assert updated.access_count == 3
def test_get_recent_nodes(self, store):
a = store.create_node(name="A", description="a", parent_id="root")
b = store.create_node(name="B", description="b", parent_id="root")
store.touch_node(a.id)
store.touch_node(b.id) # B touched last
recent = store.get_recent_nodes(limit=2)
assert len(recent) == 2
assert recent[0].id == b.id # most recent first
def test_get_recent_nodes_excludes_root(self, store):
store.touch_node("root")
recent = store.get_recent_nodes()
root_ids = [n.id for n in recent]
assert "root" not in root_ids
def test_get_top_nodes(self, store):
a = store.create_node(name="A", description="a", parent_id="root")
b = store.create_node(name="B", description="b", parent_id="root")
# Touch A more than B
for _ in range(5):
store.touch_node(a.id)
store.touch_node(b.id)
top = store.get_top_nodes(limit=2)
assert len(top) == 2
assert top[0].id == a.id # most accessed first
@pytest.mark.unit
class TestGraphVisualisation:
"""Graph data export for the canvas renderer."""
def test_get_graph_data_structure(self, store):
a = store.create_node(name="A", description="a", parent_id="root")
b = store.create_node(name="B", description="b", parent_id=a.id)
data = store.get_graph_data("root", max_depth=5)
assert "nodes" in data
assert "edges" in data
# root + seeded branches + A + B
assert len(data["nodes"]) == BOOTSTRAP_NODE_COUNT + 2
# seeded edges (root->each branch) + root->A + A->B
assert len(data["edges"]) == SEEDED + 2
def test_graph_data_includes_depth(self, store):
a = store.create_node(name="A", description="a", parent_id="root")
data = store.get_graph_data("root", max_depth=5)
root_data = next(n for n in data["nodes"] if n["id"] == "root")
a_data = next(n for n in data["nodes"] if n["id"] == a.id)
assert root_data["depth"] == 0
assert a_data["depth"] == 1
def test_graph_data_respects_max_depth(self, store):
a = store.create_node(name="A", description="a", parent_id="root")
b = store.create_node(name="B", description="b", parent_id=a.id)
c = store.create_node(name="C", description="c", parent_id=b.id)
data = store.get_graph_data("root", max_depth=1)
node_ids = {n["id"] for n in data["nodes"]}
assert "root" in node_ids
assert a.id in node_ids
# B is at depth 2, should not appear
assert b.id not in node_ids
def test_get_all_nodes(self, store):
store.create_node(name="A", description="a", parent_id="root")
store.create_node(name="B", description="b", parent_id="root")
all_nodes = store.get_all_nodes()
assert len(all_nodes) == BOOTSTRAP_NODE_COUNT + 2 # root + seeded + A + B
def test_node_count(self, store):
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT
store.create_node(name="A", description="a", parent_id="root")
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT + 1
def test_node_count_after_delete(self, store):
a = store.create_node(name="A", description="a", parent_id="root")
b = store.create_node(name="B", description="b", parent_id="root")
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT + 2
store.delete_node(a.id)
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT + 1
store.delete_node(b.id)
assert store.get_node_count() == BOOTSTRAP_NODE_COUNT
@pytest.mark.unit
class TestSafetyGuards:
"""Cycle protection, FK enforcement, and input validation."""
def test_create_node_with_invalid_parent_raises(self, store):
"""Creating a node with a non-existent parent_id must raise."""
with pytest.raises(ValueError, match="does not exist"):
store.create_node(name="Orphan", description="d", parent_id="nonexistent")
def test_get_ancestors_handles_cycle(self, store):
"""get_ancestors must not infinite loop on a cyclic parent chain."""
# Create two nodes then manually force a cycle via raw SQL
a = store.create_node(name="A", description="a", parent_id="root")
b = store.create_node(name="B", description="b", parent_id=a.id)
# Force a cycle: A -> B -> A (bypass normal validation)
with store._lock:
store.conn.execute(
"UPDATE memory_nodes SET parent_id = ? WHERE id = ?",
(b.id, a.id),
)
store.conn.commit()
# Should terminate without hanging, returning partial ancestors
ancestors = store.get_ancestors(b.id)
assert len(ancestors) <= 10 # bounded by MAX_TRAVERSAL_DEPTH + 1
def test_get_ancestors_deep_chain(self, store):
"""Ancestors traversal works correctly for deep but acyclic chains."""
parent_id = "root"
for i in range(6):
node = store.create_node(
name=f"Level{i}", description=f"depth {i}", parent_id=parent_id
)
parent_id = node.id
ancestors = store.get_ancestors(parent_id)
# root + 6 levels = 7 ancestors
assert len(ancestors) == 7
assert ancestors[0].id == "root"
assert ancestors[-1].id == parent_id
def test_unicode_node_names(self, store):
"""Nodes with unicode names, emoji, and CJK characters."""
node = store.create_node(
name="友達 🎉",
description="Japanese friend with emoji",
data="アリスは友達です。She loves 日本語。",
parent_id="root",
)
fetched = store.get_node(node.id)
assert fetched.name == "友達 🎉"
assert "アリスは友達です" in fetched.data
def test_sql_special_chars_in_data(self, store):
"""SQL metacharacters in data must round-trip safely."""
dangerous = "Robert'); DROP TABLE memory_nodes;--"
node = store.create_node(
name="Bobby Tables",
description="Test SQL injection",
data=dangerous,
parent_id="root",
)
fetched = store.get_node(node.id)
assert fetched.data == dangerous
# Table must still exist
assert store.get_node_count() >= 2
def test_search_escapes_like_wildcards(self, store):
"""Searching for literal % or _ must not behave as SQL LIKE wildcards."""
store.create_node(
name="100% Protein", description="Supplement", data="", parent_id="root"
)
store.create_node(
name="Boring Node", description="Nothing special", data="plain", parent_id="root"
)
# Searching for "100%" should only match the node with literal "100%"
results = store.search_nodes("100%")
assert len(results) == 1
assert results[0].name == "100% Protein"
def test_description_truncated_to_max_length(self, store):
"""Descriptions exceeding SUMMARY_MAX_LENGTH are truncated on create and update."""
from jarvis.memory.graph import SUMMARY_MAX_LENGTH
long_desc = "a" * (SUMMARY_MAX_LENGTH + 100)
node = store.create_node(
name="Long", description=long_desc, parent_id="root"
)
assert len(node.description) == SUMMARY_MAX_LENGTH
# Also truncated on update
updated = store.update_node(node.id, description=long_desc)
assert len(updated.description) == SUMMARY_MAX_LENGTH
def test_search_ranks_name_matches_above_data_only(self, store):
"""Nodes matching keywords in name/description should rank above
nodes that only match deep inside their data blob."""
# Specific node: keyword in name
store.create_node(
name="Work Schedule",
description="Office days and remote work pattern",
data="Monday and Thursday are in-office days.",
parent_id="root",
)
# Broad category node: keyword buried in large data
store.create_node(
name="Creative & Personal",
description="Miscellaneous personal facts",
data="The user enjoys painting on weekends. " * 50
+ "They mentioned their office once. " + "More unrelated content. " * 50,
parent_id="root",
)
results = store.search_nodes("office schedule")
assert len(results) >= 1
assert results[0].name == "Work Schedule"
@pytest.mark.unit
class TestAccessDecay:
"""Tests for time-decayed access scoring."""
def test_recently_accessed_node_ranks_higher(self, store):
"""A node accessed today should rank above one accessed long ago,
even if the stale node has a higher raw access_count.
With a 14-day half-life:
- Stale: 20 accesses, 60 days ago → 20 / (1 + 60/14) ≈ 3.78
- Fresh: 5 accesses, today → 5 / (1 + 0/14) = 5.0
"""
from datetime import timedelta
stale = store.create_node(name="Stale", description="Old node", parent_id="root")
fresh = store.create_node(name="Fresh", description="New node", parent_id="root")
# Give the stale node moderate accesses but set last_accessed to 60 days ago
store.conn.execute(
"UPDATE memory_nodes SET access_count = 20, last_accessed = ? WHERE id = ?",
((datetime.now(timezone.utc) - timedelta(days=60)).isoformat(), stale.id),
)
store.conn.commit()
# Fresh node: fewer accesses but just now
for _ in range(5):
store.touch_node(fresh.id)
top = store.get_top_nodes(limit=2)
assert len(top) >= 2
assert top[0].id == fresh.id, (
"Freshly accessed node should rank above stale node"
)
def test_children_ordered_by_decayed_score(self, store):
"""get_children should order by decayed score, not raw count.
With a 14-day half-life:
- Old child: 10 accesses, 90 days ago → 10 / (1 + 90/14) ≈ 1.35
- New child: 3 accesses, today → 3 / (1 + 0/14) = 3.0
"""
from datetime import timedelta
old_child = store.create_node(name="Old Child", description="", parent_id="root")
new_child = store.create_node(name="New Child", description="", parent_id="root")
# Old child: moderate count, very stale
store.conn.execute(
"UPDATE memory_nodes SET access_count = 10, last_accessed = ? WHERE id = ?",
((datetime.now(timezone.utc) - timedelta(days=90)).isoformat(), old_child.id),
)
store.conn.commit()
# New child: low count, fresh
for _ in range(3):
store.touch_node(new_child.id)
children = store.get_children("root")
child_ids = [c.id for c in children]
assert child_ids[0] == new_child.id
def test_same_age_nodes_ordered_by_count(self, store):
"""When two nodes were accessed at the same time, higher count wins."""
a = store.create_node(name="A", description="", parent_id="root")
b = store.create_node(name="B", description="", parent_id="root")
for _ in range(10):
store.touch_node(a.id)
for _ in range(3):
store.touch_node(b.id)
top = store.get_top_nodes(limit=2)
assert top[0].id == a.id
def test_zero_access_count_handled(self, store):
"""Nodes with zero accesses should not cause division errors."""
store.create_node(name="Untouched", description="", parent_id="root")
top = store.get_top_nodes(limit=5)
# Should not raise — zero access_count means score is 0
assert all(n.access_count >= 0 for n in top)