Skip to content

Commit bf1f3a0

Browse files
authored
Merge pull request #1 from hanamorix/week-1-scaffolding
feat: Week 1 scaffolding — brain package, CLI, tests, CI matrix
2 parents 00fe2af + 502160e commit bf1f3a0

24 files changed

Lines changed: 1127 additions & 3 deletions

.env.example

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# ═══════════════════════════════════════════════════════════
2+
# companion-emergence environment configuration
3+
# Copy this to `.env` and fill in your values. `.env` is gitignored.
4+
# ═══════════════════════════════════════════════════════════
5+
6+
# ── Core framework ────────────────────────────────────────
7+
# Override the default platformdirs location for all state.
8+
# Useful for testing, for running multiple independent instances,
9+
# or for pinning state to a specific disk.
10+
# NELLBRAIN_HOME=
11+
12+
# ── Bridge ────────────────────────────────────────────────
13+
# Host:port the bridge daemon binds to (default: 127.0.0.1:8765)
14+
# BRIDGE_BIND=
15+
16+
# ── LLM provider selection ────────────────────────────────
17+
# Overrides persona.toml's [model] section at runtime.
18+
# PROVIDER=ollama
19+
# MODEL=nell-stage13-voice
20+
21+
# ── IPC integration (optional, opt-in) ────────────────────
22+
# For NanoClaw / WhatsApp outbox integration. Leave unset to
23+
# disable; set to your JID to enable outbox→WhatsApp delivery.
24+
# Format for WhatsApp: <country-code><number>@s.whatsapp.net
25+
# NELL_IPC_JID=
26+
27+
# ── Provider API keys (if using commercial LLM) ───────────
28+
# ANTHROPIC_API_KEY=
29+
# OPENAI_API_KEY=
30+
# KIMI_API_KEY=

.gitattributes

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Normalise line endings. Prevents CRLF vs LF edit-war churn between
2+
# Hana's macOS machine and her Windows test machine.
3+
* text=auto
4+
5+
# Binary files stay binary.
6+
*.png binary
7+
*.jpg binary
8+
*.jpeg binary
9+
*.gif binary
10+
*.ico binary
11+
*.pdf binary
12+
*.gguf binary
13+
*.bin binary
14+
*.safetensors binary
15+
*.pt binary
16+
*.pth binary
17+
*.ckpt binary
18+
*.npy binary

.github/workflows/test.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
name: ${{ matrix.os }} / py ${{ matrix.python-version }}
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
os: [ubuntu-latest, macos-latest, windows-latest]
16+
python-version: ["3.12"]
17+
runs-on: ${{ matrix.os }}
18+
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@v4
22+
23+
- name: Install uv
24+
uses: astral-sh/setup-uv@v3
25+
with:
26+
enable-cache: true
27+
28+
- name: Set up Python ${{ matrix.python-version }}
29+
run: uv python install ${{ matrix.python-version }}
30+
31+
- name: Install dependencies
32+
run: uv sync --all-extras
33+
34+
- name: Run pytest
35+
run: uv run pytest -v --tb=short
36+
37+
- name: Lint with ruff
38+
run: uv run ruff check .

brain/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""companion-emergence — framework for building emotionally aware AI companions.
2+
3+
Reference implementation: Nell. See docs/source-spec/ for the full design.
4+
"""
5+
6+
__version__ = "0.0.1"

brain/cli.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Entry point CLI for companion-emergence.
2+
3+
Invoked as `nell <subcommand> [options]`. Week 1 ships `--version`, help,
4+
and a set of stub subcommands that print "not implemented yet" so the CLI
5+
surface is stable while subsequent weeks fill in functionality.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import sys
12+
from collections.abc import Callable
13+
14+
from brain import __version__
15+
16+
# Subcommands the framework plans to ship. Each is a stub in Week 1;
17+
# filled in across Weeks 2-8 as respective modules come online.
18+
_STUB_COMMANDS: tuple[str, ...] = (
19+
"supervisor",
20+
"dream",
21+
"heartbeat",
22+
"reflex",
23+
"status",
24+
"rest",
25+
"soul",
26+
"memory",
27+
"works",
28+
"migrate",
29+
)
30+
31+
32+
def _make_stub(name: str) -> Callable[[argparse.Namespace], int]:
33+
"""Factory: build a stub command handler that prints + returns 0.
34+
35+
The returned handler accepts `args: argparse.Namespace` as required by
36+
the `args.func(args)` dispatch protocol — stubs don't read it, but the
37+
signature shape is load-bearing and should not be "cleaned up" to `_args`.
38+
"""
39+
40+
def _handler(args: argparse.Namespace) -> int:
41+
print(
42+
f"nell {name} — not implemented yet. "
43+
"This subcommand is wired in a future week per the implementation plan."
44+
)
45+
return 0
46+
47+
return _handler
48+
49+
50+
def _build_parser() -> argparse.ArgumentParser:
51+
"""Construct the top-level argparse parser with all stub subcommands."""
52+
parser = argparse.ArgumentParser(
53+
prog="nell",
54+
description=("companion-emergence — CLI for building emotionally aware AI companions"),
55+
)
56+
parser.add_argument(
57+
"--version",
58+
action="version",
59+
version=f"companion-emergence {__version__}",
60+
)
61+
subparsers = parser.add_subparsers(dest="command", title="subcommands")
62+
63+
for name in _STUB_COMMANDS:
64+
sub = subparsers.add_parser(
65+
name,
66+
help=f"(stub) {name} — wired in a later week",
67+
)
68+
sub.set_defaults(func=_make_stub(name))
69+
70+
return parser
71+
72+
73+
def main(argv: list[str] | None = None) -> int:
74+
"""CLI entry point. Returns shell exit code."""
75+
parser = _build_parser()
76+
args = parser.parse_args(argv)
77+
if not args.command:
78+
parser.print_help()
79+
return 1
80+
return args.func(args)
81+
82+
83+
if __name__ == "__main__":
84+
sys.exit(main())

brain/config.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Configuration loader for companion-emergence.
2+
3+
Merges settings from three sources in increasing priority:
4+
5+
1. persona/<name>/persona.toml - persona defaults (baseline)
6+
2. .env in repo root - local overrides
7+
3. Environment variables - runtime overrides (highest priority)
8+
9+
Each resolved value is traced so startup can report the effective source.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import os
15+
import tomllib
16+
from dataclasses import dataclass, field
17+
from pathlib import Path
18+
19+
# Config keys supported by the framework. Extended in later weeks.
20+
_SUPPORTED_KEYS: tuple[str, ...] = (
21+
"BRIDGE_BIND",
22+
"PROVIDER",
23+
"MODEL",
24+
"NELL_IPC_JID",
25+
)
26+
27+
# Framework-level defaults, applied as the lowest-priority source so that
28+
# source_trace explicitly records "default" for unset keys (startup logging
29+
# can then distinguish "value came from default" from "value missing").
30+
_DEFAULTS: dict[str, str] = {
31+
"BRIDGE_BIND": "127.0.0.1:8765",
32+
"PROVIDER": "ollama",
33+
"MODEL": "",
34+
"NELL_IPC_JID": "",
35+
}
36+
37+
38+
@dataclass
39+
class Config:
40+
"""Resolved configuration for a persona session.
41+
42+
Attributes:
43+
persona_name: Name of the active persona (derived from dir basename).
44+
bridge_bind: Host:port the bridge listens on.
45+
provider: LLM provider key (ollama, claude, openai, kimi).
46+
model: Model tag for the active provider.
47+
ipc_jid: IPC target for outbox delivery (e.g. WhatsApp JID). Empty
48+
disables outbox→IPC integration.
49+
source_trace: Maps each resolved key to the source that provided it.
50+
"""
51+
52+
persona_name: str = ""
53+
bridge_bind: str = "127.0.0.1:8765"
54+
provider: str = "ollama"
55+
model: str = ""
56+
ipc_jid: str = ""
57+
source_trace: dict[str, str] = field(default_factory=dict)
58+
59+
60+
def _load_env_file(path: Path) -> dict[str, str]:
61+
"""Parse a simple KEY=VALUE .env file.
62+
63+
Supports: comment lines starting with `#`, blank lines, surrounding
64+
single/double quotes, inline comments after the value (`KEY=val # note`).
65+
Does not support: shell expansion, `export` prefix, multi-line values.
66+
Always read as UTF-8 regardless of platform default encoding.
67+
"""
68+
result: dict[str, str] = {}
69+
if not path.exists():
70+
return result
71+
for raw_line in path.read_text(encoding="utf-8").splitlines():
72+
line = raw_line.strip()
73+
if not line or line.startswith("#"):
74+
continue
75+
if "=" not in line:
76+
continue
77+
key, _, value = line.partition("=")
78+
value = value.strip()
79+
# Strip inline comment (anything after a ` #` segment). The leading
80+
# space is required so `#` inside a quoted URL or token survives.
81+
if " #" in value:
82+
value = value[: value.index(" #")].rstrip()
83+
result[key.strip()] = value.strip('"').strip("'")
84+
return result
85+
86+
87+
def _load_persona_toml(persona_dir: Path) -> dict[str, str]:
88+
"""Flatten persona.toml into the framework's config key namespace."""
89+
toml_path = persona_dir / "persona.toml"
90+
if not toml_path.exists():
91+
return {}
92+
with toml_path.open("rb") as fh:
93+
data = tomllib.load(fh)
94+
95+
flat: dict[str, str] = {}
96+
bridge = data.get("bridge", {})
97+
if "bind" in bridge:
98+
flat["BRIDGE_BIND"] = str(bridge["bind"])
99+
model = data.get("model", {})
100+
if "provider" in model:
101+
flat["PROVIDER"] = str(model["provider"])
102+
if "tag" in model:
103+
flat["MODEL"] = str(model["tag"])
104+
return flat
105+
106+
107+
def _read_env_vars() -> dict[str, str]:
108+
"""Pull supported keys from os.environ."""
109+
return {k: v for k, v in os.environ.items() if k in _SUPPORTED_KEYS}
110+
111+
112+
def load_config(persona_dir: Path, env_file: Path | None = None) -> Config:
113+
"""Load and merge config from persona.toml + .env + env vars.
114+
115+
Args:
116+
persona_dir: Directory containing persona.toml and persona data.
117+
env_file: Optional path to a .env file to load.
118+
119+
Returns:
120+
Resolved Config with source_trace recording where each value came from.
121+
"""
122+
sources: list[tuple[str, dict[str, str]]] = [
123+
("default", dict(_DEFAULTS)),
124+
("persona.toml", _load_persona_toml(persona_dir)),
125+
]
126+
if env_file is not None:
127+
sources.append((".env", _load_env_file(env_file)))
128+
sources.append(("env", _read_env_vars()))
129+
130+
trace: dict[str, str] = {}
131+
merged: dict[str, str] = {}
132+
for source_name, source_values in sources:
133+
for key, value in source_values.items():
134+
merged[key] = value
135+
trace[key] = source_name
136+
137+
return Config(
138+
persona_name=persona_dir.name,
139+
bridge_bind=merged["BRIDGE_BIND"],
140+
provider=merged["PROVIDER"],
141+
model=merged["MODEL"],
142+
ipc_jid=merged["NELL_IPC_JID"],
143+
source_trace=trace,
144+
)

brain/paths.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Platform-aware path resolution for companion-emergence.
2+
3+
All user-facing paths route through this module so we never hard-code
4+
OS-specific locations. Uses platformdirs for the OS-appropriate default
5+
with NELLBRAIN_HOME env var for full override.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import os
11+
from pathlib import Path
12+
13+
from platformdirs import PlatformDirs
14+
15+
_APP_NAME = "companion-emergence"
16+
_APP_AUTHOR = "hanamorix"
17+
18+
# PlatformDirs properties are evaluated lazily (env vars read at
19+
# property-access time, not at construction), so module-level
20+
# instantiation is safe under monkeypatching in tests.
21+
_dirs = PlatformDirs(appname=_APP_NAME, appauthor=_APP_AUTHOR)
22+
23+
24+
def get_home() -> Path:
25+
"""Root directory for all companion-emergence state.
26+
27+
Resolution order:
28+
1. NELLBRAIN_HOME env var if set (supports ~ expansion)
29+
2. platformdirs user_data_path for the current OS
30+
31+
Both branches return a fully resolved canonical path so symlinks
32+
collapse consistently (matters on Linux CI runners with symlinked
33+
home dirs).
34+
"""
35+
override = os.environ.get("NELLBRAIN_HOME")
36+
if override:
37+
return Path(override).expanduser().resolve()
38+
return _dirs.user_data_path.resolve()
39+
40+
41+
def get_persona_dir(name: str) -> Path:
42+
"""Return the directory for a specific persona's private data."""
43+
return get_home() / "personas" / name
44+
45+
46+
def get_cache_dir() -> Path:
47+
"""Return the cache directory (embeddings, computed matrices, etc)."""
48+
return _dirs.user_cache_path.resolve()
49+
50+
51+
def get_log_dir() -> Path:
52+
"""Return the log file directory."""
53+
return _dirs.user_log_path.resolve()

0 commit comments

Comments
 (0)