atbots 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atbots/__init__.py +5 -0
- atbots/agent.py +129 -0
- atbots/capabilities.py +90 -0
- atbots/cli.py +68 -0
- atbots/companion.py +238 -0
- atbots/config.py +87 -0
- atbots/domain.py +40 -0
- atbots/extensions.py +48 -0
- atbots/extraction.py +96 -0
- atbots/gateway.py +85 -0
- atbots/prompts.py +46 -0
- atbots/providers/__init__.py +11 -0
- atbots/providers/base.py +21 -0
- atbots/providers/local.py +96 -0
- atbots/providers/openai_compatible.py +91 -0
- atbots/providers/pydantic_ai.py +91 -0
- atbots/providers/router.py +51 -0
- atbots/runtime.py +220 -0
- atbots/service.py +104 -0
- atbots-0.1.0.dist-info/METADATA +295 -0
- atbots-0.1.0.dist-info/RECORD +25 -0
- atbots-0.1.0.dist-info/WHEEL +5 -0
- atbots-0.1.0.dist-info/entry_points.txt +2 -0
- atbots-0.1.0.dist-info/licenses/LICENSE +202 -0
- atbots-0.1.0.dist-info/top_level.txt +1 -0
atbots/__init__.py
ADDED
atbots/agent.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Bounded independent-agent loop driven by installed tools and skills."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from atbots.capabilities import Hooks, Tool, ToolRegistry, guard_tool_result, load_skills
|
|
12
|
+
from atbots.config import AtBotConfig
|
|
13
|
+
from atbots.providers.router import ModelRouter
|
|
14
|
+
from atbots.runtime import AtBotRuntime
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
STEP_SCHEMA: dict[str, Any] = {
|
|
18
|
+
"title": "AtBotTaskStep",
|
|
19
|
+
"type": "object",
|
|
20
|
+
"required": ["action", "reason"],
|
|
21
|
+
"properties": {
|
|
22
|
+
"action": {"enum": ["tool", "finish"]},
|
|
23
|
+
"reason": {"type": "string"},
|
|
24
|
+
"tool": {"type": ["string", "null"]},
|
|
25
|
+
"arguments": {"type": "object"},
|
|
26
|
+
"answer": {"type": ["string", "null"]},
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class TaskResult:
|
|
33
|
+
run_id: str
|
|
34
|
+
answer: str
|
|
35
|
+
status: str
|
|
36
|
+
steps: int
|
|
37
|
+
trace: tuple[dict[str, object], ...]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TaskAgent:
|
|
41
|
+
def __init__(self, config: AtBotConfig, runtime: AtBotRuntime | None = None) -> None:
|
|
42
|
+
self.config = config
|
|
43
|
+
self.runtime = runtime or AtBotRuntime(config)
|
|
44
|
+
self.router = ModelRouter(config)
|
|
45
|
+
self.hooks = Hooks()
|
|
46
|
+
self.tools = ToolRegistry(config.allowed_tools)
|
|
47
|
+
self.tools.register(
|
|
48
|
+
Tool(
|
|
49
|
+
name="memory_recall",
|
|
50
|
+
description="Retrieve governed AtMem memories relevant to a query.",
|
|
51
|
+
input_schema={"type": "object", "required": ["query"], "properties": {"query": {"type": "string"}}},
|
|
52
|
+
handler=self._memory_recall,
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
self.skills = load_skills(config.skill_directories)
|
|
56
|
+
|
|
57
|
+
def _memory_recall(self, arguments: dict[str, Any]) -> object:
|
|
58
|
+
result = self.runtime.recall(str(arguments.get("query") or ""))
|
|
59
|
+
return [{"memory": row.content, "score": row.score} for row in result.candidates]
|
|
60
|
+
|
|
61
|
+
def run(self, objective: str, *, remote: bool = False) -> TaskResult:
|
|
62
|
+
if not objective.strip():
|
|
63
|
+
raise ValueError("objective is required")
|
|
64
|
+
run_id = uuid.uuid4().hex
|
|
65
|
+
provider = self.router.select(remote=remote)
|
|
66
|
+
observations: list[str] = []
|
|
67
|
+
trace: list[dict[str, object]] = []
|
|
68
|
+
skill_names = [skill.name for skill in self.skills]
|
|
69
|
+
self.hooks.emit("task.started", {"run_id": run_id, "tool_count": len(self.tools.descriptions())})
|
|
70
|
+
if "memory_recall" in self.tools.allowed:
|
|
71
|
+
# Memory is a governed input to every independent task, not an
|
|
72
|
+
# optional fact the planning model may guess about or skip.
|
|
73
|
+
value = self.tools.invoke("memory_recall", {"query": objective})
|
|
74
|
+
observation = guard_tool_result(value)
|
|
75
|
+
observations.append(f"memory_recall: {observation}")
|
|
76
|
+
trace.append(
|
|
77
|
+
{
|
|
78
|
+
"step": 0,
|
|
79
|
+
"action": "tool",
|
|
80
|
+
"tool": "memory_recall",
|
|
81
|
+
"result_sha256": _digest(observation),
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
self.hooks.emit(
|
|
85
|
+
"tool.completed",
|
|
86
|
+
{"run_id": run_id, "step": 0, "tool": "memory_recall"},
|
|
87
|
+
)
|
|
88
|
+
for step in range(1, self.config.max_task_steps + 1):
|
|
89
|
+
prompt = json.dumps(
|
|
90
|
+
{
|
|
91
|
+
"objective": objective,
|
|
92
|
+
"tools": self.tools.descriptions(),
|
|
93
|
+
"skills": skill_names,
|
|
94
|
+
"observations": observations,
|
|
95
|
+
"instruction": "Choose one permitted tool call or finish with a useful answer.",
|
|
96
|
+
},
|
|
97
|
+
sort_keys=True,
|
|
98
|
+
)
|
|
99
|
+
result = provider.complete(
|
|
100
|
+
system="You are AtBot's bounded task loop. Never invent tool results. Return only the requested JSON.",
|
|
101
|
+
prompt=prompt,
|
|
102
|
+
schema=STEP_SCHEMA,
|
|
103
|
+
)
|
|
104
|
+
decision = result.structured or {}
|
|
105
|
+
action = decision.get("action")
|
|
106
|
+
if action == "tool" and not decision.get("tool") and decision.get("answer"):
|
|
107
|
+
# Small local models sometimes label an answer derived from a
|
|
108
|
+
# completed preflight tool as another tool action. This repair
|
|
109
|
+
# cannot invoke capability or invent data; it only terminates.
|
|
110
|
+
action = "finish"
|
|
111
|
+
if action == "finish":
|
|
112
|
+
answer = str(decision.get("answer") or decision.get("reason") or "Task completed.")
|
|
113
|
+
trace.append({"step": step, "action": "finish", "output_sha256": _digest(answer)})
|
|
114
|
+
self.hooks.emit("task.finished", {"run_id": run_id, "steps": step})
|
|
115
|
+
return TaskResult(run_id, answer, "completed", step, tuple(trace))
|
|
116
|
+
if action != "tool" or not decision.get("tool"):
|
|
117
|
+
raise RuntimeError("model returned an invalid task action")
|
|
118
|
+
name = str(decision["tool"])
|
|
119
|
+
value = self.tools.invoke(name, decision.get("arguments") or {})
|
|
120
|
+
observation = guard_tool_result(value)
|
|
121
|
+
observations.append(f"{name}: {observation}")
|
|
122
|
+
trace.append({"step": step, "action": "tool", "tool": name, "result_sha256": _digest(observation)})
|
|
123
|
+
self.hooks.emit("tool.completed", {"run_id": run_id, "step": step, "tool": name})
|
|
124
|
+
self.hooks.emit("task.stopped", {"run_id": run_id, "reason": "step_limit"})
|
|
125
|
+
return TaskResult(run_id, "Task stopped at the configured step limit.", "step_limit", self.config.max_task_steps, tuple(trace))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _digest(value: str) -> str:
|
|
129
|
+
return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
|
atbots/capabilities.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Typed tool, skill, hook, guardrail, and policy primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
ToolHandler = Callable[[dict[str, Any]], object]
|
|
12
|
+
HookHandler = Callable[[str, dict[str, Any]], None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Tool:
|
|
17
|
+
name: str
|
|
18
|
+
description: str
|
|
19
|
+
input_schema: dict[str, Any]
|
|
20
|
+
handler: ToolHandler
|
|
21
|
+
destructive: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ToolRegistry:
|
|
25
|
+
def __init__(self, allowed: list[str]) -> None:
|
|
26
|
+
self.allowed = frozenset(allowed)
|
|
27
|
+
self._tools: dict[str, Tool] = {}
|
|
28
|
+
|
|
29
|
+
def register(self, tool: Tool) -> None:
|
|
30
|
+
if tool.name in self._tools:
|
|
31
|
+
raise ValueError(f"tool already registered: {tool.name}")
|
|
32
|
+
self._tools[tool.name] = tool
|
|
33
|
+
|
|
34
|
+
def descriptions(self) -> list[dict[str, object]]:
|
|
35
|
+
return [
|
|
36
|
+
{"name": tool.name, "description": tool.description, "input_schema": tool.input_schema}
|
|
37
|
+
for name, tool in sorted(self._tools.items())
|
|
38
|
+
if name in self.allowed
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
def invoke(self, name: str, arguments: dict[str, Any]) -> object:
|
|
42
|
+
if name not in self.allowed:
|
|
43
|
+
raise PermissionError(f"tool is not permitted by this capability profile: {name}")
|
|
44
|
+
tool = self._tools.get(name)
|
|
45
|
+
if tool is None:
|
|
46
|
+
raise ValueError(f"tool is not installed: {name}")
|
|
47
|
+
if tool.destructive:
|
|
48
|
+
raise PermissionError(f"destructive tool requires a separate approval boundary: {name}")
|
|
49
|
+
return tool.handler(arguments)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class Skill:
|
|
54
|
+
name: str
|
|
55
|
+
instructions: str
|
|
56
|
+
source: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_skills(directories: list[str]) -> tuple[Skill, ...]:
|
|
60
|
+
skills: list[Skill] = []
|
|
61
|
+
for directory in directories:
|
|
62
|
+
root = Path(directory).expanduser().resolve(strict=False)
|
|
63
|
+
if not root.is_dir():
|
|
64
|
+
continue
|
|
65
|
+
for source in sorted(root.glob("*/SKILL.md")):
|
|
66
|
+
text = source.read_text(encoding="utf-8")
|
|
67
|
+
if len(text) <= 100_000:
|
|
68
|
+
skills.append(Skill(source.parent.name, text, str(source)))
|
|
69
|
+
return tuple(skills)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Hooks:
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self._handlers: list[HookHandler] = []
|
|
75
|
+
|
|
76
|
+
def add(self, handler: HookHandler) -> None:
|
|
77
|
+
self._handlers.append(handler)
|
|
78
|
+
|
|
79
|
+
def emit(self, event: str, payload: dict[str, Any]) -> None:
|
|
80
|
+
# Callers must provide content-free metadata suitable for traces.
|
|
81
|
+
json.dumps(payload)
|
|
82
|
+
for handler in tuple(self._handlers):
|
|
83
|
+
handler(event, payload)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def guard_tool_result(value: object, *, limit: int = 20_000) -> str:
|
|
87
|
+
text = json.dumps(value, default=str, sort_keys=True)
|
|
88
|
+
if len(text) > limit:
|
|
89
|
+
return text[:limit] + "…[truncated]"
|
|
90
|
+
return text
|
atbots/cli.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Development CLI for the AtMem intelligence companion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from atbots.companion import CompanionRuntime
|
|
11
|
+
from atbots.config import DEFAULT_CONFIG, AtBotConfig, ProviderConfig, load_config, save_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="atbots", description="Headless local-first intelligence companion for AtMem"
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument("--config", default=str(DEFAULT_CONFIG))
|
|
19
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
20
|
+
init = commands.add_parser("init", help="Configure the local AtMem companion")
|
|
21
|
+
init.add_argument("--model", default="qwen3:4b")
|
|
22
|
+
init.add_argument("--endpoint", default="http://127.0.0.1:11434")
|
|
23
|
+
init.add_argument("--force", action="store_true")
|
|
24
|
+
commands.add_parser("status", help="Show companion capabilities and providers")
|
|
25
|
+
commands.add_parser("doctor", help="Check companion readiness")
|
|
26
|
+
serve = commands.add_parser("serve", help="Run the private headless companion")
|
|
27
|
+
serve.add_argument("--host")
|
|
28
|
+
serve.add_argument("--port", type=int)
|
|
29
|
+
return parser
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: list[str] | None = None) -> int:
|
|
33
|
+
args = _parser().parse_args(argv)
|
|
34
|
+
try:
|
|
35
|
+
if args.command == "init":
|
|
36
|
+
target = Path(args.config).expanduser()
|
|
37
|
+
if target.exists() and not args.force:
|
|
38
|
+
raise ValueError(f"configuration already exists: {target} (use --force)")
|
|
39
|
+
config = AtBotConfig(
|
|
40
|
+
profile="memory-companion",
|
|
41
|
+
providers=[ProviderConfig(model=args.model, endpoint=args.endpoint)],
|
|
42
|
+
)
|
|
43
|
+
save_config(config, target)
|
|
44
|
+
print(f"AtBot companion configured: {target}")
|
|
45
|
+
print(f"Local model: {args.model} via Ollama")
|
|
46
|
+
print("AtMem owns storage and the customer dashboard.")
|
|
47
|
+
return 0
|
|
48
|
+
config = load_config(args.config)
|
|
49
|
+
companion = CompanionRuntime(config)
|
|
50
|
+
if args.command in {"status", "doctor"}:
|
|
51
|
+
value = companion.capabilities()
|
|
52
|
+
value["config_path"] = str(Path(args.config).expanduser())
|
|
53
|
+
print(json.dumps(value, indent=2, sort_keys=True))
|
|
54
|
+
ready = any(bool(row.get("available")) for row in value["providers"])
|
|
55
|
+
return 0 if ready else 1
|
|
56
|
+
if args.command == "serve":
|
|
57
|
+
from atbots.service import serve
|
|
58
|
+
|
|
59
|
+
serve(config, host=args.host or config.host, port=args.port or config.port)
|
|
60
|
+
return 0
|
|
61
|
+
except (FileNotFoundError, ValueError, RuntimeError) as exc:
|
|
62
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
63
|
+
return 2
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
raise SystemExit(main())
|
atbots/companion.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Headless AtMem intelligence companion; no canonical memory ownership."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from atbots.config import AtBotConfig
|
|
9
|
+
from atbots.extraction import extract_facts
|
|
10
|
+
from atbots.providers.router import ModelRouter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
QUERY_SCHEMA: dict[str, Any] = {
|
|
14
|
+
"title": "AtBotMemoryQuery",
|
|
15
|
+
"type": "object",
|
|
16
|
+
"required": ["answer", "ranked_record_ids", "explanation"],
|
|
17
|
+
"properties": {
|
|
18
|
+
"answer": {"type": "string"},
|
|
19
|
+
"ranked_record_ids": {"type": "array", "items": {"type": "string"}},
|
|
20
|
+
"explanation": {"type": "string"},
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CompanionRuntime:
|
|
26
|
+
"""Processes only work already scoped and authorized by AtMem."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, config: AtBotConfig) -> None:
|
|
29
|
+
self.config = config
|
|
30
|
+
self.router = ModelRouter(config)
|
|
31
|
+
|
|
32
|
+
def capabilities(self) -> dict[str, object]:
|
|
33
|
+
return {
|
|
34
|
+
"format": "atbot-companion-capabilities-v1",
|
|
35
|
+
"role": "atmem-intelligence-companion",
|
|
36
|
+
"independent_agent": False,
|
|
37
|
+
"canonical_storage": False,
|
|
38
|
+
"features": {
|
|
39
|
+
"eligible_candidate_query": True,
|
|
40
|
+
"reranking": True,
|
|
41
|
+
"query_expansion": True,
|
|
42
|
+
"proposal_extraction": True,
|
|
43
|
+
},
|
|
44
|
+
"providers": self.router.status(),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def expand_query(self, query: str) -> dict[str, object]:
|
|
48
|
+
"""Expand query concepts without receiving any memory content."""
|
|
49
|
+
clean = " ".join(query.split())
|
|
50
|
+
if not clean:
|
|
51
|
+
raise ValueError("query is required")
|
|
52
|
+
lowered = clean.casefold()
|
|
53
|
+
expansions = [clean]
|
|
54
|
+
concept_rules = (
|
|
55
|
+
(("fav food", "favorite food", "favourite food"), ("favorite food", "food preference", "preferred meal", "likes to eat")),
|
|
56
|
+
(("fav car", "favorite car", "favourite car"), ("favorite car", "car preference", "preferred vehicle")),
|
|
57
|
+
(("fav book", "favorite book", "favourite book"), ("favorite book", "book preference", "preferred reading")),
|
|
58
|
+
)
|
|
59
|
+
for triggers, values in concept_rules:
|
|
60
|
+
if any(trigger in lowered for trigger in triggers):
|
|
61
|
+
expansions.extend(values)
|
|
62
|
+
normalized = []
|
|
63
|
+
for value in expansions:
|
|
64
|
+
item = " ".join(str(value).split())[:200]
|
|
65
|
+
if item and item.casefold() not in {row.casefold() for row in normalized}:
|
|
66
|
+
normalized.append(item)
|
|
67
|
+
if len(normalized) >= 6:
|
|
68
|
+
break
|
|
69
|
+
return {
|
|
70
|
+
"format": "atbot-query-expansion-v1",
|
|
71
|
+
"query": clean,
|
|
72
|
+
"expanded_queries": normalized,
|
|
73
|
+
"content_received": False,
|
|
74
|
+
"provider": "atbot-policy",
|
|
75
|
+
"model": "query-concepts-v1",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def propose_memories(self, message: str, *, remote: bool = False) -> dict[str, object]:
|
|
79
|
+
"""Interpret one source message without storing or authorizing anything."""
|
|
80
|
+
clean = " ".join(message.split())
|
|
81
|
+
if not clean:
|
|
82
|
+
raise ValueError("message is required")
|
|
83
|
+
if len(clean) > 20_000:
|
|
84
|
+
raise ValueError("message is too large")
|
|
85
|
+
provider = self.router.select(sensitivity="personal", remote=remote)
|
|
86
|
+
facts = extract_facts(provider, clean)
|
|
87
|
+
return {
|
|
88
|
+
"format": "atbot-memory-proposals-v1",
|
|
89
|
+
"proposals": [
|
|
90
|
+
{
|
|
91
|
+
"fact": fact.fact,
|
|
92
|
+
"fact_key": fact.fact_key,
|
|
93
|
+
"confidence": fact.confidence,
|
|
94
|
+
"sensitivity": fact.sensitivity,
|
|
95
|
+
"entities": list(fact.entities),
|
|
96
|
+
"suggested_action": fact.suggested_action,
|
|
97
|
+
# AtBot did not receive eligible records on this endpoint,
|
|
98
|
+
# so it cannot create record relationships here.
|
|
99
|
+
"related_record_ids": [],
|
|
100
|
+
}
|
|
101
|
+
for fact in facts
|
|
102
|
+
],
|
|
103
|
+
"interpreter": {
|
|
104
|
+
"provider": provider.name,
|
|
105
|
+
"model": provider.model,
|
|
106
|
+
"prompt_version": "atbot-extract-v1",
|
|
107
|
+
"assurance": "model_interpreted",
|
|
108
|
+
"egress_class": provider.egress_class,
|
|
109
|
+
},
|
|
110
|
+
"content_received": True,
|
|
111
|
+
"authority_decision": None,
|
|
112
|
+
"canonical_storage": False,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
def answer_query(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
query: str,
|
|
119
|
+
candidates: list[dict[str, object]],
|
|
120
|
+
remote: bool = False,
|
|
121
|
+
) -> dict[str, object]:
|
|
122
|
+
clean = " ".join(query.split())
|
|
123
|
+
if not clean:
|
|
124
|
+
raise ValueError("query is required")
|
|
125
|
+
if len(candidates) > 100:
|
|
126
|
+
raise ValueError("AtMem sent too many eligible candidates")
|
|
127
|
+
allowed: dict[str, dict[str, object]] = {}
|
|
128
|
+
for row in candidates:
|
|
129
|
+
record_id = str(row.get("record_id") or row.get("id") or "").strip()
|
|
130
|
+
content = " ".join(str(row.get("content") or row.get("match_excerpt") or "").split())
|
|
131
|
+
if not record_id or not content or len(content) > 4_000 or _source_noise(content):
|
|
132
|
+
continue
|
|
133
|
+
allowed[record_id] = {
|
|
134
|
+
"record_id": record_id,
|
|
135
|
+
"content": content,
|
|
136
|
+
"score": float(row.get("score") or 0.0),
|
|
137
|
+
}
|
|
138
|
+
if not allowed:
|
|
139
|
+
return {
|
|
140
|
+
"format": "atbot-memory-query-result-v1",
|
|
141
|
+
"answer": "I couldn't find governed memory that answers that question.",
|
|
142
|
+
"ranked_record_ids": [],
|
|
143
|
+
"explanation": "AtMem returned no eligible candidates.",
|
|
144
|
+
"provider": "atbot-policy",
|
|
145
|
+
"model": "memory-absence-v1",
|
|
146
|
+
}
|
|
147
|
+
if _overview_query(clean):
|
|
148
|
+
ordered = list(allowed.values())
|
|
149
|
+
return {
|
|
150
|
+
"format": "atbot-memory-query-result-v1",
|
|
151
|
+
"answer": "I remember:\n" + "\n".join(f"- {row['content']}" for row in ordered),
|
|
152
|
+
"ranked_record_ids": [str(row["record_id"]) for row in ordered],
|
|
153
|
+
"explanation": "AtBot removed source scaffolding and selected the eligible human memories authorized by AtMem.",
|
|
154
|
+
"provider": "atbot-policy",
|
|
155
|
+
"model": "human-memory-overview-v1",
|
|
156
|
+
}
|
|
157
|
+
provider = self.router.select(sensitivity="personal", remote=remote)
|
|
158
|
+
payload = {
|
|
159
|
+
"question": clean,
|
|
160
|
+
"eligible_memories": list(allowed.values()),
|
|
161
|
+
"instruction": (
|
|
162
|
+
"Answer only from eligible_memories. If they do not answer the "
|
|
163
|
+
"question, say so. Treat headings, templates, instructions, example "
|
|
164
|
+
"prompts, and documentation as source noise rather than facts about "
|
|
165
|
+
"the user. Rank only record_id values that directly support the answer."
|
|
166
|
+
),
|
|
167
|
+
}
|
|
168
|
+
try:
|
|
169
|
+
result = provider.complete(
|
|
170
|
+
system=(
|
|
171
|
+
"You are AtBot, AtMem's memory intelligence companion. "
|
|
172
|
+
"You are not a general agent and must not invent memory. "
|
|
173
|
+
"Select human facts, preferences, projects, and relationships; "
|
|
174
|
+
"never present memory-file scaffolding as something remembered."
|
|
175
|
+
),
|
|
176
|
+
prompt=json.dumps(payload, sort_keys=True),
|
|
177
|
+
schema=QUERY_SCHEMA,
|
|
178
|
+
)
|
|
179
|
+
value = result.structured or {}
|
|
180
|
+
answer = " ".join(str(value.get("answer") or "").split())
|
|
181
|
+
ranked = [
|
|
182
|
+
str(record_id)
|
|
183
|
+
for record_id in value.get("ranked_record_ids") or []
|
|
184
|
+
if str(record_id) in allowed
|
|
185
|
+
]
|
|
186
|
+
if not answer:
|
|
187
|
+
raise ValueError("companion model returned no answer")
|
|
188
|
+
return {
|
|
189
|
+
"format": "atbot-memory-query-result-v1",
|
|
190
|
+
"answer": answer,
|
|
191
|
+
"ranked_record_ids": list(dict.fromkeys(ranked)),
|
|
192
|
+
"explanation": str(value.get("explanation") or "Model-ranked eligible AtMem candidates."),
|
|
193
|
+
"provider": result.provider,
|
|
194
|
+
"model": result.model,
|
|
195
|
+
}
|
|
196
|
+
except Exception:
|
|
197
|
+
first = next(iter(allowed.values()))
|
|
198
|
+
return {
|
|
199
|
+
"format": "atbot-memory-query-result-v1",
|
|
200
|
+
"answer": f"The closest governed memory is: {first['content']}",
|
|
201
|
+
"ranked_record_ids": [str(first["record_id"])],
|
|
202
|
+
"explanation": "AtBot used its deterministic local fallback.",
|
|
203
|
+
"provider": "atbot-policy",
|
|
204
|
+
"model": "eligible-candidate-fallback-v1",
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _overview_query(query: str) -> bool:
|
|
209
|
+
text = query.casefold()
|
|
210
|
+
return any(
|
|
211
|
+
phrase in text
|
|
212
|
+
for phrase in (
|
|
213
|
+
"what do you remember",
|
|
214
|
+
"what do you know about me",
|
|
215
|
+
"list my memories",
|
|
216
|
+
"show my memories",
|
|
217
|
+
"everything you remember",
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _source_noise(content: str) -> bool:
|
|
223
|
+
"""Remove obvious Markdown scaffolding before model ranking."""
|
|
224
|
+
text = content.strip()
|
|
225
|
+
lowered = text.casefold()
|
|
226
|
+
if text in {"---", "---.", "Notes:.", "## Related.", "## Context."}:
|
|
227
|
+
return True
|
|
228
|
+
if text.startswith("#") or (text.startswith("- [") and "](" in text):
|
|
229
|
+
return True
|
|
230
|
+
return any(
|
|
231
|
+
phrase in lowered
|
|
232
|
+
for phrase in (
|
|
233
|
+
"learn about the person you're helping",
|
|
234
|
+
"what do they care about? what projects",
|
|
235
|
+
"the more you know, the better you can help",
|
|
236
|
+
"fill this in during your first conversation",
|
|
237
|
+
)
|
|
238
|
+
)
|
atbots/config.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Small explicit AtBot configuration with safe local-first defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_ROOT = Path.home() / ".atbots"
|
|
12
|
+
DEFAULT_CONFIG = DEFAULT_ROOT / "config.json"
|
|
13
|
+
LEGACY_DEFAULT_CONFIG = Path.home() / ".atbot" / "config.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class ProviderConfig:
|
|
18
|
+
name: str = "local"
|
|
19
|
+
kind: str = "ollama"
|
|
20
|
+
model: str = "qwen3:4b"
|
|
21
|
+
endpoint: str = "http://127.0.0.1:11434"
|
|
22
|
+
api_key_env: str | None = None
|
|
23
|
+
egress_class: str = "local"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class AtBotConfig:
|
|
28
|
+
format: str = "atbot-config-v1"
|
|
29
|
+
memory_path: str = str(DEFAULT_ROOT / "atmem.db")
|
|
30
|
+
subject_id: str = "local-user"
|
|
31
|
+
agent_id: str = "atbot-main"
|
|
32
|
+
workspace_id: str = "private"
|
|
33
|
+
profile: str = "memory-companion"
|
|
34
|
+
host: str = "127.0.0.1"
|
|
35
|
+
port: int = 8770
|
|
36
|
+
recent_message_limit: int = 10
|
|
37
|
+
remote_egress_allowed: bool = False
|
|
38
|
+
max_task_steps: int = 8
|
|
39
|
+
allowed_tools: list[str] = field(default_factory=lambda: ["memory_recall"])
|
|
40
|
+
skill_directories: list[str] = field(default_factory=list)
|
|
41
|
+
pydantic_capabilities: list[str] = field(default_factory=list)
|
|
42
|
+
providers: list[ProviderConfig] = field(default_factory=lambda: [ProviderConfig()])
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def memory_file(self) -> Path:
|
|
46
|
+
return Path(self.memory_path).expanduser().resolve(strict=False)
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, Any]:
|
|
49
|
+
return asdict(self)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, value: dict[str, Any]) -> "AtBotConfig":
|
|
53
|
+
if value.get("format") != "atbot-config-v1":
|
|
54
|
+
raise ValueError("unsupported AtBot config format")
|
|
55
|
+
providers = [ProviderConfig(**row) for row in value.get("providers") or []]
|
|
56
|
+
return cls(
|
|
57
|
+
**{
|
|
58
|
+
key: item
|
|
59
|
+
for key, item in value.items()
|
|
60
|
+
if key not in {"providers"}
|
|
61
|
+
},
|
|
62
|
+
providers=providers or [ProviderConfig()],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config(path: str | Path = DEFAULT_CONFIG) -> AtBotConfig:
|
|
67
|
+
source = Path(path).expanduser()
|
|
68
|
+
if source == DEFAULT_CONFIG and not source.is_file() and LEGACY_DEFAULT_CONFIG.is_file():
|
|
69
|
+
source = LEGACY_DEFAULT_CONFIG
|
|
70
|
+
if not source.is_file():
|
|
71
|
+
raise FileNotFoundError(
|
|
72
|
+
f"AtBots is not configured: {source}. Run `atbots init` first."
|
|
73
|
+
)
|
|
74
|
+
return AtBotConfig.from_dict(json.loads(source.read_text(encoding="utf-8")))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_config(config: AtBotConfig, path: str | Path = DEFAULT_CONFIG) -> Path:
|
|
78
|
+
target = Path(path).expanduser()
|
|
79
|
+
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
80
|
+
temporary = target.with_suffix(target.suffix + ".tmp")
|
|
81
|
+
temporary.write_text(
|
|
82
|
+
json.dumps(config.to_dict(), indent=2, sort_keys=True) + "\n",
|
|
83
|
+
encoding="utf-8",
|
|
84
|
+
)
|
|
85
|
+
temporary.chmod(0o600)
|
|
86
|
+
temporary.replace(target)
|
|
87
|
+
return target
|
atbots/domain.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""AtBot-owned intelligence and task types; no canonical memory state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class ExtractedFact:
|
|
11
|
+
fact: str
|
|
12
|
+
fact_key: str | None = None
|
|
13
|
+
confidence: float = 0.7
|
|
14
|
+
sensitivity: str = "personal"
|
|
15
|
+
entities: tuple[dict[str, str], ...] = ()
|
|
16
|
+
suggested_action: str = "add"
|
|
17
|
+
related_record_ids: tuple[str, ...] = ()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ProviderResult:
|
|
22
|
+
text: str
|
|
23
|
+
structured: dict[str, Any] | None
|
|
24
|
+
provider: str
|
|
25
|
+
model: str
|
|
26
|
+
egress_class: str
|
|
27
|
+
input_tokens: int | None = None
|
|
28
|
+
output_tokens: int | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class ChatResult:
|
|
33
|
+
text: str
|
|
34
|
+
run_id: str
|
|
35
|
+
provider: str
|
|
36
|
+
model: str
|
|
37
|
+
memory_record_ids: tuple[str, ...] = ()
|
|
38
|
+
context_receipt_id: str | None = None
|
|
39
|
+
cache_key: str | None = None
|
|
40
|
+
trace: tuple[dict[str, Any], ...] = ()
|