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.
- package/VERSION +1 -1
- package/config/constitution.yaml +6 -0
- package/config/hooks/user-prompt-submit-v2.sh +33 -40
- package/core/budget/__init__.py +6 -0
- package/core/budget/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/budget/__pycache__/manager.cpython-313.pyc +0 -0
- package/core/budget/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/budget/manager.py +193 -0
- package/core/budget/schema.py +82 -0
- package/core/knowledge/__init__.py +6 -0
- package/core/knowledge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/chunker.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/indexer.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
- package/core/knowledge/chunker.py +121 -0
- package/core/knowledge/embedder.py +52 -0
- package/core/knowledge/indexer.py +97 -0
- package/core/knowledge/vector_store.py +213 -0
- package/core/obsidian/__init__.py +6 -0
- package/core/obsidian/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/templates.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/writer.cpython-313.pyc +0 -0
- package/core/obsidian/templates.py +76 -0
- package/core/obsidian/writer.py +148 -0
- package/core/orchestration/__init__.py +6 -0
- package/core/orchestration/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/orchestration/__pycache__/patterns.cpython-313.pyc +0 -0
- package/core/orchestration/__pycache__/protocol.cpython-313.pyc +0 -0
- package/core/orchestration/patterns.py +136 -0
- package/core/orchestration/protocol.py +96 -0
- package/core/runtime/__pycache__/subagent.cpython-313.pyc +0 -0
- package/core/runtime/subagent.py +5 -0
- package/core/squads/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/squads/schema.py +3 -0
- package/core/squads/templates/project-squad.yaml +28 -0
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/synapse/engine.py +5 -1
- package/core/synapse/layers.py +95 -9
- package/core/tasks/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/tasks/schema.py +7 -0
- package/core/workflow/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/workflow/engine.py +44 -0
- package/core/workflow/schema.py +1 -0
- package/departments/dev/agents/research-assistant.yaml +51 -0
- package/departments/kb/agents/data-collector.yaml +51 -0
- package/departments/ops/agents/doc-writer.yaml +51 -0
- package/departments/pm/agents/pm-director.yaml +1 -1
- package/installer/cli.js +36 -0
- package/installer/init.js +105 -0
- package/installer/migrate.js +4 -1
- package/package.json +1 -1
- 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)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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"]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|