arkaos 2.0.1 → 2.0.3

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.
Files changed (55) hide show
  1. package/VERSION +1 -1
  2. package/config/constitution.yaml +6 -0
  3. package/config/hooks/user-prompt-submit-v2.sh +33 -40
  4. package/core/budget/__init__.py +6 -0
  5. package/core/budget/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/core/budget/__pycache__/manager.cpython-313.pyc +0 -0
  7. package/core/budget/__pycache__/schema.cpython-313.pyc +0 -0
  8. package/core/budget/manager.py +193 -0
  9. package/core/budget/schema.py +82 -0
  10. package/core/knowledge/__init__.py +6 -0
  11. package/core/knowledge/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/core/knowledge/__pycache__/chunker.cpython-313.pyc +0 -0
  13. package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
  14. package/core/knowledge/__pycache__/indexer.cpython-313.pyc +0 -0
  15. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  16. package/core/knowledge/chunker.py +121 -0
  17. package/core/knowledge/embedder.py +52 -0
  18. package/core/knowledge/indexer.py +97 -0
  19. package/core/knowledge/vector_store.py +213 -0
  20. package/core/obsidian/__init__.py +6 -0
  21. package/core/obsidian/__pycache__/__init__.cpython-313.pyc +0 -0
  22. package/core/obsidian/__pycache__/templates.cpython-313.pyc +0 -0
  23. package/core/obsidian/__pycache__/writer.cpython-313.pyc +0 -0
  24. package/core/obsidian/templates.py +76 -0
  25. package/core/obsidian/writer.py +148 -0
  26. package/core/orchestration/__init__.py +6 -0
  27. package/core/orchestration/__pycache__/__init__.cpython-313.pyc +0 -0
  28. package/core/orchestration/__pycache__/patterns.cpython-313.pyc +0 -0
  29. package/core/orchestration/__pycache__/protocol.cpython-313.pyc +0 -0
  30. package/core/orchestration/patterns.py +136 -0
  31. package/core/orchestration/protocol.py +96 -0
  32. package/core/runtime/__pycache__/subagent.cpython-313.pyc +0 -0
  33. package/core/runtime/subagent.py +5 -0
  34. package/core/squads/__pycache__/schema.cpython-313.pyc +0 -0
  35. package/core/squads/schema.py +3 -0
  36. package/core/squads/templates/project-squad.yaml +28 -0
  37. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  38. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  39. package/core/synapse/engine.py +5 -1
  40. package/core/synapse/layers.py +95 -9
  41. package/core/tasks/__pycache__/schema.cpython-313.pyc +0 -0
  42. package/core/tasks/schema.py +7 -0
  43. package/core/workflow/__pycache__/engine.cpython-313.pyc +0 -0
  44. package/core/workflow/__pycache__/schema.cpython-313.pyc +0 -0
  45. package/core/workflow/engine.py +44 -0
  46. package/core/workflow/schema.py +1 -0
  47. package/departments/dev/agents/research-assistant.yaml +51 -0
  48. package/departments/kb/agents/data-collector.yaml +51 -0
  49. package/departments/ops/agents/doc-writer.yaml +51 -0
  50. package/departments/pm/agents/pm-director.yaml +1 -1
  51. package/installer/cli.js +36 -0
  52. package/installer/init.js +105 -0
  53. package/installer/migrate.js +4 -1
  54. package/package.json +1 -1
  55. package/pyproject.toml +5 -1
@@ -0,0 +1,97 @@
1
+ """Knowledge indexer — walk directories and index markdown files.
2
+
3
+ Supports incremental indexing (skips already-indexed files by hash).
4
+ """
5
+
6
+ import hashlib
7
+ from pathlib import Path
8
+ from typing import Callable, Optional
9
+
10
+ from core.knowledge.chunker import chunk_markdown
11
+ from core.knowledge.vector_store import VectorStore
12
+
13
+
14
+ def file_hash(path: Path) -> str:
15
+ """Compute SHA-256 hash of file content."""
16
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
17
+
18
+
19
+ def index_directory(
20
+ directory: str | Path,
21
+ store: VectorStore,
22
+ pattern: str = "**/*.md",
23
+ on_progress: Optional[Callable[[int, int, str], None]] = None,
24
+ max_tokens: int = 512,
25
+ skip_indexed: bool = True,
26
+ ) -> dict:
27
+ """Index all markdown files in a directory.
28
+
29
+ Args:
30
+ directory: Root directory to scan.
31
+ store: VectorStore to index into.
32
+ pattern: Glob pattern for files.
33
+ on_progress: Callback(current, total, filename).
34
+ max_tokens: Max tokens per chunk.
35
+ skip_indexed: Skip files already indexed (by hash).
36
+
37
+ Returns:
38
+ Dict with: files_scanned, files_indexed, files_skipped, chunks_created.
39
+ """
40
+ root = Path(directory)
41
+ if not root.exists():
42
+ return {"files_scanned": 0, "files_indexed": 0, "files_skipped": 0, "chunks_created": 0}
43
+
44
+ files = sorted(root.glob(pattern))
45
+ # Skip hidden dirs (.obsidian, .git)
46
+ files = [f for f in files if not any(part.startswith(".") for part in f.relative_to(root).parts)]
47
+
48
+ total = len(files)
49
+ indexed = 0
50
+ skipped = 0
51
+ chunks_created = 0
52
+
53
+ for i, filepath in enumerate(files):
54
+ if on_progress:
55
+ on_progress(i + 1, total, filepath.name)
56
+
57
+ fhash = file_hash(filepath)
58
+
59
+ if skip_indexed and store.is_file_indexed(fhash):
60
+ skipped += 1
61
+ continue
62
+
63
+ try:
64
+ content = filepath.read_text(encoding="utf-8")
65
+ except (OSError, UnicodeDecodeError):
66
+ skipped += 1
67
+ continue
68
+
69
+ # Skip very small files
70
+ if len(content.split()) < 20:
71
+ skipped += 1
72
+ continue
73
+
74
+ # Remove old chunks for this file (re-index)
75
+ store.remove_file(str(filepath))
76
+
77
+ # Chunk and index
78
+ chunks = chunk_markdown(content, max_tokens=max_tokens, source=str(filepath))
79
+ if chunks:
80
+ texts = [c.text for c in chunks]
81
+ headings = [c.heading for c in chunks]
82
+ count = store.index_chunks(
83
+ texts=texts,
84
+ headings=headings,
85
+ source=str(filepath),
86
+ file_hash=fhash,
87
+ metadata={"relative_path": str(filepath.relative_to(root))},
88
+ )
89
+ chunks_created += count
90
+ indexed += 1
91
+
92
+ return {
93
+ "files_scanned": total,
94
+ "files_indexed": indexed,
95
+ "files_skipped": skipped,
96
+ "chunks_created": chunks_created,
97
+ }
@@ -0,0 +1,213 @@
1
+ """Vector store — SQLite-VSS backed semantic search.
2
+
3
+ Stores document chunks with embeddings for fast similarity search.
4
+ Graceful degradation: works without sqlite-vss (brute-force fallback).
5
+ """
6
+
7
+ import json
8
+ import sqlite3
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any, Optional
12
+
13
+ from core.knowledge.embedder import embed, embed_batch, EMBEDDING_DIMS
14
+
15
+
16
+ def _load_vss(db: sqlite3.Connection) -> bool:
17
+ """Try to load sqlite-vss extension."""
18
+ try:
19
+ db.enable_load_extension(True)
20
+ import sqlite_vss
21
+ sqlite_vss.load(db)
22
+ return True
23
+ except (ImportError, Exception):
24
+ return False
25
+
26
+
27
+ class VectorStore:
28
+ """SQLite-VSS backed vector store for knowledge retrieval."""
29
+
30
+ def __init__(self, db_path: str | Path = ":memory:") -> None:
31
+ self._db_path = str(db_path)
32
+ self._db = sqlite3.connect(self._db_path)
33
+ self._db.row_factory = sqlite3.Row
34
+ self._vss_available = _load_vss(self._db)
35
+ self._init_schema()
36
+
37
+ def _init_schema(self) -> None:
38
+ """Create tables if they don't exist."""
39
+ self._db.executescript("""
40
+ CREATE TABLE IF NOT EXISTS chunks (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ text TEXT NOT NULL,
43
+ heading TEXT DEFAULT '',
44
+ source TEXT DEFAULT '',
45
+ file_hash TEXT DEFAULT '',
46
+ metadata TEXT DEFAULT '{}',
47
+ created_at REAL DEFAULT (unixepoch('now')),
48
+ embedding BLOB
49
+ );
50
+ CREATE INDEX IF NOT EXISTS idx_chunks_source ON chunks(source);
51
+ CREATE INDEX IF NOT EXISTS idx_chunks_hash ON chunks(file_hash);
52
+ """)
53
+ if self._vss_available:
54
+ try:
55
+ self._db.execute(
56
+ f"CREATE VIRTUAL TABLE IF NOT EXISTS vss_chunks USING vss0(embedding({EMBEDDING_DIMS}))"
57
+ )
58
+ except Exception:
59
+ self._vss_available = False
60
+ self._db.commit()
61
+
62
+ def index_chunks(
63
+ self,
64
+ texts: list[str],
65
+ headings: list[str] | None = None,
66
+ source: str = "",
67
+ file_hash: str = "",
68
+ metadata: dict[str, Any] | None = None,
69
+ ) -> int:
70
+ """Index multiple text chunks with embeddings.
71
+
72
+ Returns number of chunks indexed.
73
+ """
74
+ if not texts:
75
+ return 0
76
+
77
+ embeddings = embed_batch(texts)
78
+ meta_json = json.dumps(metadata or {})
79
+ count = 0
80
+
81
+ for i, text in enumerate(texts):
82
+ heading = headings[i] if headings and i < len(headings) else ""
83
+ emb_blob = None
84
+
85
+ if embeddings and i < len(embeddings):
86
+ emb_blob = _vec_to_blob(embeddings[i])
87
+
88
+ cursor = self._db.execute(
89
+ "INSERT INTO chunks (text, heading, source, file_hash, metadata, embedding) VALUES (?, ?, ?, ?, ?, ?)",
90
+ (text, heading, source, file_hash, meta_json, emb_blob),
91
+ )
92
+
93
+ if self._vss_available and emb_blob:
94
+ self._db.execute(
95
+ "INSERT INTO vss_chunks (rowid, embedding) VALUES (?, ?)",
96
+ (cursor.lastrowid, emb_blob),
97
+ )
98
+ count += 1
99
+
100
+ self._db.commit()
101
+ return count
102
+
103
+ def search(self, query: str, top_k: int = 5) -> list[dict]:
104
+ """Search for similar chunks.
105
+
106
+ Returns list of dicts with: text, heading, source, score, metadata.
107
+ """
108
+ # Check if store has any data
109
+ total = self._db.execute("SELECT COUNT(*) as cnt FROM chunks").fetchone()["cnt"]
110
+ if total == 0:
111
+ return []
112
+
113
+ query_emb = embed(query)
114
+
115
+ if query_emb and self._vss_available:
116
+ try:
117
+ return self._vss_search(query_emb, top_k)
118
+ except Exception:
119
+ return self._keyword_search(query, top_k)
120
+
121
+ # Fallback: keyword search
122
+ return self._keyword_search(query, top_k)
123
+
124
+ def _vss_search(self, query_emb: list[float], top_k: int) -> list[dict]:
125
+ """Vector similarity search via sqlite-vss."""
126
+ query_blob = _vec_to_blob(query_emb)
127
+ rows = self._db.execute("""
128
+ SELECT c.text, c.heading, c.source, c.metadata, v.distance
129
+ FROM vss_chunks v
130
+ JOIN chunks c ON c.id = v.rowid
131
+ WHERE vss_search(v.embedding, vss_search_params(?, ?))
132
+ """, (query_blob, top_k)).fetchall()
133
+
134
+ return [
135
+ {
136
+ "text": r["text"],
137
+ "heading": r["heading"],
138
+ "source": r["source"],
139
+ "score": 1.0 - r["distance"], # Convert distance to similarity
140
+ "metadata": json.loads(r["metadata"]),
141
+ }
142
+ for r in rows
143
+ ]
144
+
145
+ def _keyword_search(self, query: str, top_k: int) -> list[dict]:
146
+ """Fallback keyword search when VSS unavailable."""
147
+ words = query.lower().split()
148
+ if not words:
149
+ return []
150
+
151
+ conditions = " OR ".join(["lower(text) LIKE ?" for _ in words])
152
+ params = [f"%{w}%" for w in words[:5]] # Max 5 keywords
153
+
154
+ rows = self._db.execute(
155
+ f"SELECT text, heading, source, metadata FROM chunks WHERE {conditions} LIMIT ?",
156
+ params + [top_k],
157
+ ).fetchall()
158
+
159
+ return [
160
+ {
161
+ "text": r["text"],
162
+ "heading": r["heading"],
163
+ "source": r["source"],
164
+ "score": 0.5, # No real score for keyword search
165
+ "metadata": json.loads(r["metadata"]),
166
+ }
167
+ for r in rows
168
+ ]
169
+
170
+ def is_file_indexed(self, file_hash: str) -> bool:
171
+ """Check if a file has already been indexed."""
172
+ row = self._db.execute(
173
+ "SELECT COUNT(*) as cnt FROM chunks WHERE file_hash = ?", (file_hash,)
174
+ ).fetchone()
175
+ return row["cnt"] > 0
176
+
177
+ def remove_file(self, source: str) -> int:
178
+ """Remove all chunks from a source file."""
179
+ if self._vss_available:
180
+ rows = self._db.execute("SELECT id FROM chunks WHERE source = ?", (source,)).fetchall()
181
+ for r in rows:
182
+ self._db.execute("DELETE FROM vss_chunks WHERE rowid = ?", (r["id"],))
183
+ deleted = self._db.execute("DELETE FROM chunks WHERE source = ?", (source,)).rowcount
184
+ self._db.commit()
185
+ return deleted
186
+
187
+ def get_stats(self) -> dict:
188
+ """Get store statistics."""
189
+ total = self._db.execute("SELECT COUNT(*) as cnt FROM chunks").fetchone()["cnt"]
190
+ sources = self._db.execute("SELECT COUNT(DISTINCT source) as cnt FROM chunks").fetchone()["cnt"]
191
+ return {
192
+ "total_chunks": total,
193
+ "total_files": sources,
194
+ "vss_available": self._vss_available,
195
+ "db_path": self._db_path,
196
+ }
197
+
198
+ def clear(self) -> None:
199
+ """Remove all data."""
200
+ if self._vss_available:
201
+ self._db.execute("DELETE FROM vss_chunks")
202
+ self._db.execute("DELETE FROM chunks")
203
+ self._db.commit()
204
+
205
+ def close(self) -> None:
206
+ """Close database connection."""
207
+ self._db.close()
208
+
209
+
210
+ def _vec_to_blob(vec: list[float]) -> bytes:
211
+ """Convert float vector to bytes for SQLite storage."""
212
+ import struct
213
+ return struct.pack(f"{len(vec)}f", *vec)
@@ -0,0 +1,6 @@
1
+ """Obsidian vault integration — write workflow outputs to knowledge base."""
2
+
3
+ from core.obsidian.writer import ObsidianWriter
4
+ from core.obsidian.templates import build_frontmatter
5
+
6
+ __all__ = ["ObsidianWriter", "build_frontmatter"]
@@ -0,0 +1,76 @@
1
+ """Obsidian frontmatter templates and conventions."""
2
+
3
+ from datetime import date, datetime
4
+ from typing import Any
5
+
6
+
7
+ def build_frontmatter(
8
+ department: str = "",
9
+ agent: str = "",
10
+ workflow: str = "",
11
+ tags: list[str] | None = None,
12
+ extra: dict[str, Any] | None = None,
13
+ ) -> str:
14
+ """Build YAML frontmatter for an Obsidian note.
15
+
16
+ Returns:
17
+ String with opening/closing --- delimiters.
18
+ """
19
+ fields: dict[str, Any] = {}
20
+ fields["created"] = datetime.now().strftime("%Y-%m-%d %H:%M")
21
+ fields["source"] = "arkaos"
22
+
23
+ if department:
24
+ fields["department"] = department
25
+ if agent:
26
+ fields["agent"] = agent
27
+ if workflow:
28
+ fields["workflow"] = workflow
29
+
30
+ all_tags = ["arkaos"]
31
+ if department:
32
+ all_tags.append(f"dept/{department}")
33
+ if tags:
34
+ all_tags.extend(tags)
35
+ fields["tags"] = all_tags
36
+
37
+ if extra:
38
+ fields.update(extra)
39
+
40
+ lines = ["---"]
41
+ for key, value in fields.items():
42
+ if isinstance(value, list):
43
+ lines.append(f"{key}:")
44
+ for item in value:
45
+ lines.append(f" - {item}")
46
+ elif isinstance(value, bool):
47
+ lines.append(f"{key}: {'true' if value else 'false'}")
48
+ else:
49
+ lines.append(f"{key}: {value}")
50
+ lines.append("---")
51
+
52
+ return "\n".join(lines)
53
+
54
+
55
+ def resolve_template_vars(path: str, vars: dict[str, str] | None = None) -> str:
56
+ """Resolve template variables in an Obsidian path.
57
+
58
+ Supported variables:
59
+ {project}, {department}, {agent}, {date}, {number}, {name}
60
+ """
61
+ defaults = {
62
+ "date": date.today().isoformat(),
63
+ "project": "Default",
64
+ "department": "general",
65
+ "agent": "unknown",
66
+ "number": "001",
67
+ "name": "untitled",
68
+ }
69
+ if vars:
70
+ defaults.update(vars)
71
+
72
+ result = path
73
+ for key, value in defaults.items():
74
+ result = result.replace(f"{{{key}}}", value)
75
+
76
+ return result
@@ -0,0 +1,148 @@
1
+ """Obsidian vault writer — save workflow outputs to knowledge base.
2
+
3
+ Resolves vault path from:
4
+ 1. Constructor argument
5
+ 2. knowledge/obsidian-config.json → vault_path
6
+ 3. ARKAOS_VAULT environment variable
7
+ 4. Fallback: ~/.arkaos/vault/
8
+ """
9
+
10
+ import json
11
+ import os
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ from core.obsidian.templates import build_frontmatter, resolve_template_vars
17
+
18
+
19
+ class ObsidianWriter:
20
+ """Writes markdown files to an Obsidian vault with frontmatter."""
21
+
22
+ def __init__(self, vault_path: str | Path | None = None, arkaos_root: str | Path | None = None) -> None:
23
+ self._vault_path = self._resolve_vault_path(vault_path, arkaos_root)
24
+
25
+ def save(
26
+ self,
27
+ obsidian_path: str,
28
+ content: str,
29
+ department: str = "",
30
+ agent: str = "",
31
+ workflow: str = "",
32
+ tags: list[str] | None = None,
33
+ template_vars: dict[str, str] | None = None,
34
+ extra_frontmatter: dict[str, Any] | None = None,
35
+ ) -> Path:
36
+ """Save content to the Obsidian vault.
37
+
38
+ Args:
39
+ obsidian_path: Relative path within vault (may contain template vars).
40
+ content: Markdown content to save.
41
+ department: Source department.
42
+ agent: Agent that produced the output.
43
+ workflow: Workflow that generated this output.
44
+ tags: Additional tags for frontmatter.
45
+ template_vars: Variables to resolve in path ({project}, {date}, etc.).
46
+ extra_frontmatter: Additional frontmatter fields.
47
+
48
+ Returns:
49
+ Absolute path to the saved file.
50
+ """
51
+ # Resolve template variables in path
52
+ resolved_path = resolve_template_vars(obsidian_path, template_vars)
53
+
54
+ # Build full path
55
+ full_path = self._vault_path / resolved_path
56
+
57
+ # If path doesn't end with .md, treat as directory and add filename
58
+ if not full_path.suffix:
59
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H%M")
60
+ filename = f"{department or 'output'}-{timestamp}.md"
61
+ full_path = full_path / filename
62
+ elif full_path.suffix != ".md":
63
+ full_path = full_path.with_suffix(".md")
64
+
65
+ # Handle duplicate filenames
66
+ if full_path.exists():
67
+ stem = full_path.stem
68
+ timestamp = datetime.now().strftime("%H%M%S")
69
+ full_path = full_path.with_stem(f"{stem}-{timestamp}")
70
+
71
+ # Create directories
72
+ full_path.parent.mkdir(parents=True, exist_ok=True)
73
+
74
+ # Build frontmatter
75
+ frontmatter = build_frontmatter(
76
+ department=department,
77
+ agent=agent,
78
+ workflow=workflow,
79
+ tags=tags,
80
+ extra=extra_frontmatter,
81
+ )
82
+
83
+ # Write file
84
+ file_content = f"{frontmatter}\n\n{content}"
85
+ full_path.write_text(file_content, encoding="utf-8")
86
+
87
+ return full_path
88
+
89
+ def ensure_vault(self) -> bool:
90
+ """Verify the vault directory exists."""
91
+ return self._vault_path.exists()
92
+
93
+ def list_outputs(self, department: str = "", limit: int = 50) -> list[Path]:
94
+ """List recent ArkaOS outputs in the vault."""
95
+ if not self._vault_path.exists():
96
+ return []
97
+
98
+ pattern = "**/*.md"
99
+ files = sorted(self._vault_path.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
100
+
101
+ results = []
102
+ for f in files:
103
+ if len(results) >= limit:
104
+ break
105
+ try:
106
+ head = f.read_text(encoding="utf-8")[:200]
107
+ if "source: arkaos" in head:
108
+ if not department or f"department: {department}" in head:
109
+ results.append(f)
110
+ except (OSError, UnicodeDecodeError):
111
+ continue
112
+
113
+ return results
114
+
115
+ @property
116
+ def vault_path(self) -> Path:
117
+ return self._vault_path
118
+
119
+ def _resolve_vault_path(self, explicit: str | Path | None, arkaos_root: str | Path | None) -> Path:
120
+ """Resolve vault path from multiple sources."""
121
+ # 1. Explicit argument
122
+ if explicit:
123
+ return Path(explicit)
124
+
125
+ # 2. Config file
126
+ if arkaos_root:
127
+ config_path = Path(arkaos_root) / "knowledge" / "obsidian-config.json"
128
+ else:
129
+ config_path = Path(__file__).resolve().parent.parent.parent / "knowledge" / "obsidian-config.json"
130
+
131
+ if config_path.exists():
132
+ try:
133
+ config = json.loads(config_path.read_text())
134
+ vault = config.get("vault_path", "")
135
+ if vault and Path(vault).exists():
136
+ return Path(vault)
137
+ except (json.JSONDecodeError, OSError):
138
+ pass
139
+
140
+ # 3. Environment variable
141
+ env_vault = os.environ.get("ARKAOS_VAULT", "")
142
+ if env_vault and Path(env_vault).exists():
143
+ return Path(env_vault)
144
+
145
+ # 4. Fallback
146
+ fallback = Path.home() / ".arkaos" / "vault"
147
+ fallback.mkdir(parents=True, exist_ok=True)
148
+ return fallback
@@ -0,0 +1,6 @@
1
+ """Orchestration protocol — coordination patterns for multi-agent work."""
2
+
3
+ from core.orchestration.protocol import OrchestrationPattern, PhaseHandoff, OrchestrationPlan
4
+ from core.orchestration.patterns import PATTERNS
5
+
6
+ __all__ = ["OrchestrationPattern", "PhaseHandoff", "OrchestrationPlan", "PATTERNS"]