arkaos 2.90.0 → 2.91.0

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 CHANGED
@@ -1 +1 @@
1
- 2.90.0
1
+ 2.91.0
@@ -13,24 +13,81 @@ from typing import Any, Optional
13
13
  from core.knowledge.embedder import embed, embed_batch, EMBEDDING_DIMS
14
14
 
15
15
 
16
+ _VEC_UNAVAILABLE_REASON: str = "" # set by _load_vec on failure
17
+
18
+
16
19
  def _load_vec(db: sqlite3.Connection) -> bool:
17
- """Try to load sqlite-vec extension."""
20
+ """Try to load sqlite-vec extension.
21
+
22
+ PR73 v2.91.0 — failure reason is now captured in a module-level
23
+ string so the dashboard / /api/knowledge/stats can surface why
24
+ vector search is unavailable instead of just "Unavailable".
25
+ Common reasons:
26
+ - sqlite-vec not installed (pip install sqlite-vec)
27
+ - Python sqlite3 built without enable_load_extension (rare on
28
+ homebrew Python; common on system Python on some distros)
29
+ """
30
+ global _VEC_UNAVAILABLE_REASON
18
31
  try:
19
32
  db.enable_load_extension(True)
20
- import sqlite_vec
33
+ except AttributeError:
34
+ _VEC_UNAVAILABLE_REASON = (
35
+ "Python sqlite3 was built without extension loading support. "
36
+ "On macOS, reinstall via Homebrew Python: brew install python."
37
+ )
38
+ return False
39
+ except sqlite3.OperationalError as exc:
40
+ _VEC_UNAVAILABLE_REASON = f"sqlite3 refused extension loading: {exc}"
41
+ return False
42
+ try:
43
+ import sqlite_vec # noqa: PLC0415
44
+ except ImportError:
45
+ _VEC_UNAVAILABLE_REASON = (
46
+ "sqlite-vec package missing. Install with: "
47
+ "pip install sqlite-vec"
48
+ )
49
+ return False
50
+ try:
21
51
  sqlite_vec.load(db)
52
+ _VEC_UNAVAILABLE_REASON = ""
22
53
  return True
23
- except (ImportError, Exception):
54
+ except Exception as exc: # noqa: BLE001
55
+ _VEC_UNAVAILABLE_REASON = f"sqlite_vec.load failed: {exc}"
24
56
  return False
25
57
 
26
58
 
59
+ def vec_unavailable_reason() -> str:
60
+ """Public accessor for the last vec-load failure reason."""
61
+ return _VEC_UNAVAILABLE_REASON
62
+
63
+
27
64
  class VectorStore:
28
- """SQLite-vec backed vector store for knowledge retrieval."""
65
+ """SQLite-vec backed vector store for knowledge retrieval.
66
+
67
+ PR73 v2.91.0 — connection is opened with ``check_same_thread=False``
68
+ so background ingest workers (knowledge_ingest, knowledge_ingest_bulk
69
+ in scripts/dashboard-api.py) can reuse a long-lived FastAPI-scoped
70
+ store instance without hitting ``sqlite3.ProgrammingError: SQLite
71
+ objects created in a thread can only be used in that same thread``.
72
+ Concurrent writes are serialised via ``_write_lock``; SQLite's WAL
73
+ journal_mode lets readers continue while a writer holds the lock.
74
+ """
29
75
 
30
76
  def __init__(self, db_path: str | Path = ":memory:") -> None:
77
+ import threading
31
78
  self._db_path = str(db_path)
32
- self._db = sqlite3.connect(self._db_path)
79
+ # check_same_thread=False — see class docstring.
80
+ self._db = sqlite3.connect(self._db_path, check_same_thread=False)
33
81
  self._db.row_factory = sqlite3.Row
82
+ # WAL gives concurrent readers + a single writer at the engine
83
+ # level. The Python lock below serialises our application-level
84
+ # writes per VectorStore instance.
85
+ try:
86
+ self._db.execute("PRAGMA journal_mode=WAL")
87
+ except sqlite3.OperationalError:
88
+ # in-memory DBs don't support WAL — harmless.
89
+ pass
90
+ self._write_lock = threading.Lock()
34
91
  self._vec_available = _load_vec(self._db)
35
92
  self._init_schema()
36
93
 
@@ -102,26 +159,31 @@ class VectorStore:
102
159
  meta_json = json.dumps(metadata or {})
103
160
  count = 0
104
161
 
105
- for i, text in enumerate(texts):
106
- heading = headings[i] if headings and i < len(headings) else ""
107
- emb_blob = None
108
-
109
- if embeddings and i < len(embeddings):
110
- emb_blob = _vec_to_blob(embeddings[i])
111
-
112
- cursor = self._db.execute(
113
- "INSERT INTO chunks (text, heading, source, file_hash, metadata, embedding) VALUES (?, ?, ?, ?, ?, ?)",
114
- (text, heading, source, file_hash, meta_json, emb_blob),
115
- )
116
-
117
- if self._vec_available and emb_blob:
118
- self._db.execute(
119
- "INSERT INTO vec_chunks (rowid, embedding) VALUES (?, ?)",
120
- (cursor.lastrowid, emb_blob),
162
+ # PR73 v2.91.0 serialise writes from background ingest threads.
163
+ # check_same_thread=False on the connection lets the threads use
164
+ # `self._db`; the lock ensures only one writer at a time so
165
+ # cursor lastrowid stays consistent.
166
+ with self._write_lock:
167
+ for i, text in enumerate(texts):
168
+ heading = headings[i] if headings and i < len(headings) else ""
169
+ emb_blob = None
170
+
171
+ if embeddings and i < len(embeddings):
172
+ emb_blob = _vec_to_blob(embeddings[i])
173
+
174
+ cursor = self._db.execute(
175
+ "INSERT INTO chunks (text, heading, source, file_hash, metadata, embedding) VALUES (?, ?, ?, ?, ?, ?)",
176
+ (text, heading, source, file_hash, meta_json, emb_blob),
121
177
  )
122
- count += 1
123
178
 
124
- self._db.commit()
179
+ if self._vec_available and emb_blob:
180
+ self._db.execute(
181
+ "INSERT INTO vec_chunks (rowid, embedding) VALUES (?, ?)",
182
+ (cursor.lastrowid, emb_blob),
183
+ )
184
+ count += 1
185
+
186
+ self._db.commit()
125
187
  return count
126
188
 
127
189
  def search(self, query: str, top_k: int = 5) -> list[dict]:
@@ -201,13 +263,14 @@ class VectorStore:
201
263
 
202
264
  def remove_file(self, source: str) -> int:
203
265
  """Remove all chunks from a source file."""
204
- if self._vec_available:
205
- rows = self._db.execute("SELECT id FROM chunks WHERE source = ?", (source,)).fetchall()
206
- for r in rows:
207
- self._db.execute("DELETE FROM vec_chunks WHERE rowid = ?", (r["id"],))
208
- deleted = self._db.execute("DELETE FROM chunks WHERE source = ?", (source,)).rowcount
209
- self._db.commit()
210
- return deleted
266
+ with self._write_lock:
267
+ if self._vec_available:
268
+ rows = self._db.execute("SELECT id FROM chunks WHERE source = ?", (source,)).fetchall()
269
+ for r in rows:
270
+ self._db.execute("DELETE FROM vec_chunks WHERE rowid = ?", (r["id"],))
271
+ deleted = self._db.execute("DELETE FROM chunks WHERE source = ?", (source,)).rowcount
272
+ self._db.commit()
273
+ return deleted
211
274
 
212
275
  def get_stats(self) -> dict:
213
276
  """Get store statistics."""
@@ -0,0 +1,276 @@
1
+ """Obsidian-backed persona store (PR73 v2.91.0).
2
+
3
+ Reads personas from ``<vaultPath>/Personas/*.md`` and writes new
4
+ personas back to the same folder. The vault path comes from
5
+ ``~/.arkaos/profile.json::vaultPath``; when that's empty / missing,
6
+ the store gracefully degrades to a no-op (the JSON store keeps
7
+ working).
8
+
9
+ Per the operator's instruction: "personas podiamos ir buscar a lista
10
+ aqui o que esta no obsidian acho que é o mais sensato e quando criamos
11
+ guardamos no obsidian tambem". This module implements both directions.
12
+
13
+ Schema (frontmatter):
14
+ type: persona
15
+ name: <full name>
16
+ source: <one-line summary>
17
+ title: <role label>
18
+ tagline: <essence>
19
+ mbti: <4-letter>
20
+ disc: { primary, secondary }
21
+ enneagram: { type, wing }
22
+ big_five: { openness, ... }
23
+ mental_models: [list]
24
+ expertise: [list] # legacy alias for expertise_domains
25
+ expertise_domains: [list]
26
+ frameworks: [list]
27
+ key_quotes: [list]
28
+ communication: { tone, vocabulary_level, preferred_format, avoid }
29
+
30
+ Unknown frontmatter keys are ignored. Body markdown is preserved
31
+ when reading but not re-injected into the Persona model (the model
32
+ doesn't carry free-form notes).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ import uuid
39
+ from datetime import datetime, timezone
40
+ from pathlib import Path
41
+ from typing import Any, Iterable
42
+
43
+ from core.personas.schema import (
44
+ Persona,
45
+ PersonaBigFive,
46
+ PersonaCommunication,
47
+ PersonaDISC,
48
+ PersonaEnneagram,
49
+ )
50
+ from core.profile import ProfileManager
51
+
52
+
53
+ _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)\Z", re.DOTALL)
54
+ _PERSONAS_SUBDIR = "Personas"
55
+
56
+
57
+ _UNSET = object()
58
+
59
+
60
+ class ObsidianPersonaStore:
61
+ """Read / write personas as Markdown files in an Obsidian vault."""
62
+
63
+ def __init__(self, vault_path: Path | None | object = _UNSET) -> None:
64
+ # Distinguish "not passed" (auto-resolve from profile) from
65
+ # "passed as None" (explicitly no vault — tests use this).
66
+ if vault_path is _UNSET:
67
+ self._vault_path = self._resolve_vault_path()
68
+ else:
69
+ self._vault_path = vault_path # type: ignore[assignment]
70
+
71
+ @property
72
+ def available(self) -> bool:
73
+ return bool(self._vault_path and self.personas_dir.exists())
74
+
75
+ @property
76
+ def personas_dir(self) -> Path:
77
+ return (self._vault_path or Path()) / _PERSONAS_SUBDIR
78
+
79
+ @staticmethod
80
+ def _resolve_vault_path() -> Path | None:
81
+ try:
82
+ profile = ProfileManager().read()
83
+ except Exception: # noqa: BLE001
84
+ return None
85
+ if not profile.vaultPath:
86
+ return None
87
+ path = Path(profile.vaultPath).expanduser()
88
+ return path if path.exists() else None
89
+
90
+ # ─── Read ────────────────────────────────────────────────────────────
91
+
92
+ def list_all(self) -> list[Persona]:
93
+ """Return every parseable persona in <vault>/Personas/."""
94
+ if not self.available:
95
+ return []
96
+ out: list[Persona] = []
97
+ for md_path in sorted(self.personas_dir.glob("*.md")):
98
+ persona = self._read_file(md_path)
99
+ if persona is not None:
100
+ out.append(persona)
101
+ return out
102
+
103
+ def _read_file(self, md_path: Path) -> Persona | None:
104
+ try:
105
+ text = md_path.read_text(encoding="utf-8", errors="replace")
106
+ except OSError:
107
+ return None
108
+ fm = self._parse_frontmatter(text)
109
+ if not fm:
110
+ return None
111
+ # MOC files etc. can also live in this folder; skip anything not
112
+ # explicitly typed as persona.
113
+ if str(fm.get("type", "")).lower() != "persona":
114
+ return None
115
+ return self._frontmatter_to_persona(fm, md_path)
116
+
117
+ @staticmethod
118
+ def _parse_frontmatter(text: str) -> dict[str, Any] | None:
119
+ match = _FRONTMATTER_RE.match(text)
120
+ if not match:
121
+ return None
122
+ try:
123
+ import yaml # noqa: PLC0415
124
+ except ImportError:
125
+ return None
126
+ try:
127
+ data = yaml.safe_load(match.group(1)) or {}
128
+ except Exception: # noqa: BLE001 — yaml parsers raise many shapes
129
+ return None
130
+ return data if isinstance(data, dict) else None
131
+
132
+ @staticmethod
133
+ def _frontmatter_to_persona(fm: dict, md_path: Path) -> Persona:
134
+ name = str(fm.get("name") or md_path.stem)
135
+ disc_fm = fm.get("disc") if isinstance(fm.get("disc"), dict) else {}
136
+ enneagram_fm = fm.get("enneagram") if isinstance(fm.get("enneagram"), dict) else {}
137
+ big_five_fm = fm.get("big_five") if isinstance(fm.get("big_five"), dict) else {}
138
+ comm_fm = fm.get("communication") if isinstance(fm.get("communication"), dict) else {}
139
+ expertise = _as_str_list(fm.get("expertise_domains") or fm.get("expertise"))
140
+
141
+ return Persona(
142
+ id=ObsidianPersonaStore._slug_from_name(name),
143
+ name=name,
144
+ title=str(fm.get("title") or ""),
145
+ tagline=str(fm.get("tagline") or ""),
146
+ source=str(fm.get("source") or name),
147
+ disc=PersonaDISC(**_filter_known(PersonaDISC, disc_fm)),
148
+ enneagram=PersonaEnneagram(**_filter_known(PersonaEnneagram, enneagram_fm)),
149
+ big_five=PersonaBigFive(**_filter_known(PersonaBigFive, big_five_fm)),
150
+ mbti=str(fm.get("mbti") or "INTJ"),
151
+ mental_models=_as_str_list(fm.get("mental_models")),
152
+ expertise_domains=expertise,
153
+ frameworks=_as_str_list(fm.get("frameworks")),
154
+ key_quotes=_as_str_list(fm.get("key_quotes")),
155
+ communication=PersonaCommunication(
156
+ **_filter_known(PersonaCommunication, comm_fm),
157
+ ),
158
+ created_at=str(fm.get("created_at") or ""),
159
+ updated_at=str(fm.get("updated_at") or ""),
160
+ )
161
+
162
+ @staticmethod
163
+ def _slug_from_name(name: str) -> str:
164
+ return (
165
+ name.lower()
166
+ .replace(" ", "-")
167
+ .replace(".", "")
168
+ .replace("'", "")
169
+ ) or str(uuid.uuid4())
170
+
171
+ # ─── Write ───────────────────────────────────────────────────────────
172
+
173
+ def write(self, persona: Persona) -> Path | None:
174
+ """Persist a persona as <vault>/Personas/<name>.md.
175
+
176
+ Returns the path written, or None when the vault isn't
177
+ configured. Existing files are overwritten — the operator
178
+ is the source of truth.
179
+ """
180
+ if not self._vault_path:
181
+ return None
182
+ self.personas_dir.mkdir(parents=True, exist_ok=True)
183
+ target = self.personas_dir / f"{persona.name}.md"
184
+ body = self._render(persona)
185
+ tmp = target.with_suffix(target.suffix + ".tmp")
186
+ try:
187
+ tmp.write_text(body, encoding="utf-8")
188
+ tmp.replace(target)
189
+ except OSError:
190
+ try:
191
+ tmp.unlink()
192
+ except FileNotFoundError:
193
+ pass
194
+ return None
195
+ return target
196
+
197
+ @staticmethod
198
+ def _render(persona: Persona) -> str:
199
+ try:
200
+ import yaml # noqa: PLC0415
201
+ except ImportError:
202
+ yaml = None # type: ignore[assignment]
203
+ now = datetime.now(timezone.utc).isoformat()
204
+ frontmatter = {
205
+ "type": "persona",
206
+ "name": persona.name,
207
+ "source": persona.source or persona.name,
208
+ "title": persona.title,
209
+ "tagline": persona.tagline,
210
+ "mbti": persona.mbti,
211
+ "disc": {
212
+ "primary": persona.disc.primary,
213
+ "secondary": persona.disc.secondary,
214
+ },
215
+ "enneagram": {
216
+ "type": persona.enneagram.type,
217
+ "wing": persona.enneagram.wing,
218
+ },
219
+ "big_five": {
220
+ "openness": persona.big_five.openness,
221
+ "conscientiousness": persona.big_five.conscientiousness,
222
+ "extraversion": persona.big_five.extraversion,
223
+ "agreeableness": persona.big_five.agreeableness,
224
+ "neuroticism": persona.big_five.neuroticism,
225
+ },
226
+ "mental_models": list(persona.mental_models),
227
+ "expertise_domains": list(persona.expertise_domains),
228
+ "frameworks": list(persona.frameworks),
229
+ "key_quotes": list(persona.key_quotes),
230
+ "communication": {
231
+ "tone": persona.communication.tone,
232
+ "vocabulary_level": persona.communication.vocabulary_level,
233
+ "preferred_format": persona.communication.preferred_format,
234
+ "avoid": list(persona.communication.avoid),
235
+ },
236
+ "created_at": persona.created_at or now,
237
+ "updated_at": now,
238
+ }
239
+ if yaml is not None:
240
+ fm_block = yaml.safe_dump(
241
+ frontmatter,
242
+ sort_keys=False,
243
+ allow_unicode=True,
244
+ default_flow_style=False,
245
+ )
246
+ else:
247
+ fm_block = "\n".join(f"{k}: {v}" for k, v in frontmatter.items()) + "\n"
248
+ body = "# " + persona.name + "\n\n"
249
+ if persona.tagline:
250
+ body += f"> {persona.tagline}\n\n"
251
+ body += "## Source\n\n" + (persona.source or persona.name) + "\n\n"
252
+ if persona.mental_models:
253
+ body += "## Mental Models\n\n"
254
+ body += "\n".join(f"- {m}" for m in persona.mental_models) + "\n\n"
255
+ if persona.key_quotes:
256
+ body += "## Key Quotes\n\n"
257
+ body += "\n".join(f"> {q}" for q in persona.key_quotes) + "\n\n"
258
+ return f"---\n{fm_block}---\n\n{body}"
259
+
260
+
261
+ # ─── Helpers ────────────────────────────────────────────────────────────
262
+
263
+
264
+ def _as_str_list(value: Any) -> list[str]:
265
+ if not isinstance(value, list):
266
+ return []
267
+ return [str(item) for item in value if isinstance(item, (str, int, float))]
268
+
269
+
270
+ def _filter_known(model: type, data: dict) -> dict:
271
+ """Drop keys the Pydantic model doesn't declare so unknown
272
+ frontmatter keys (e.g. older schema variants) don't crash."""
273
+ if not isinstance(data, dict):
274
+ return {}
275
+ known: Iterable[str] = model.model_fields.keys() # type: ignore[attr-defined]
276
+ return {k: v for k, v in data.items() if k in known}
@@ -325,6 +325,13 @@ function formatScore(score: number): string {
325
325
  return `${(score * 100).toFixed(0)}%`
326
326
  }
327
327
 
328
+ // PR73 v2.91.0 — `vec_available` is the canonical PR47-era flag from
329
+ // the new VectorStore; `vss_available` is the legacy field name from
330
+ // earlier sqlite-vss builds. Treat either as "active".
331
+ const vectorSearchActive = computed(() =>
332
+ Boolean(stats.value?.vec_available || stats.value?.vss_available),
333
+ )
334
+
328
335
  // PR71 v2.88.0 — delete all chunks from a given source.
329
336
 
330
337
  const deletingSource = ref<string | null>(null)
@@ -419,9 +426,9 @@ function escapeRegex(value: string): string {
419
426
  </template>
420
427
  <template #trailing>
421
428
  <UBadge
422
- v-if="stats?.vss_available !== undefined"
423
- :label="stats.vss_available ? 'VSS Active' : 'VSS Unavailable'"
424
- :color="stats.vss_available ? 'success' : 'neutral'"
429
+ v-if="stats?.vec_available !== undefined || stats?.vss_available !== undefined"
430
+ :label="vectorSearchActive ? 'Vector Active' : 'Vector Off'"
431
+ :color="vectorSearchActive ? 'success' : 'warning'"
425
432
  variant="subtle"
426
433
  />
427
434
  </template>
@@ -768,12 +775,19 @@ function escapeRegex(value: string): string {
768
775
  </div>
769
776
  <div class="rounded-lg border border-default p-4 text-center">
770
777
  <UBadge
771
- :label="stats?.vss_available ? 'Available' : 'Unavailable'"
772
- :color="stats?.vss_available ? 'success' : 'neutral'"
778
+ :label="vectorSearchActive ? 'Active' : 'Unavailable'"
779
+ :color="vectorSearchActive ? 'success' : 'warning'"
773
780
  variant="subtle"
774
781
  size="sm"
775
782
  />
776
783
  <p class="text-xs text-muted mt-1">Vector Search</p>
784
+ <p
785
+ v-if="!vectorSearchActive && stats?.vec_unavailable_reason"
786
+ class="text-xs text-yellow-400 mt-2 text-left"
787
+ :title="stats.vec_unavailable_reason"
788
+ >
789
+ {{ stats.vec_unavailable_reason }}
790
+ </p>
777
791
  </div>
778
792
  </div>
779
793
 
@@ -128,6 +128,10 @@ export interface KnowledgeStats {
128
128
  total_chunks: number
129
129
  total_files: number
130
130
  vss_available?: boolean
131
+ // PR73 v2.91.0 — vec_available + reason surfaced by /api/knowledge/stats
132
+ vec_available?: boolean
133
+ vec_unavailable_reason?: string
134
+ indexed?: boolean
131
135
  areas?: {
132
136
  name: string
133
137
  chunks: number
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.90.0",
3
+ "version": "2.91.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "2.90.0"
3
+ version = "2.91.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -595,11 +595,29 @@ def task_detail(task_id: str):
595
595
 
596
596
  @app.get("/api/knowledge/stats")
597
597
  def knowledge_stats():
598
+ """PR73 v2.91.0 — vec_unavailable_reason surfaced so the dashboard
599
+ can show *why* vector search is offline instead of just a generic
600
+ "Unavailable" badge."""
598
601
  store = _get_vector_store()
599
602
  if not store:
600
- return {"total_chunks": 0, "total_files": 0, "vss_available": False, "indexed": False}
603
+ return {
604
+ "total_chunks": 0,
605
+ "total_files": 0,
606
+ "vss_available": False,
607
+ "vec_available": False,
608
+ "vec_unavailable_reason": "Vector store could not be opened.",
609
+ "indexed": False,
610
+ }
601
611
  stats = store.get_stats()
602
612
  stats["indexed"] = stats["total_chunks"] > 0
613
+ if not stats.get("vec_available", False):
614
+ try:
615
+ from core.knowledge.vector_store import vec_unavailable_reason
616
+ stats["vec_unavailable_reason"] = vec_unavailable_reason()
617
+ except Exception:
618
+ stats["vec_unavailable_reason"] = "unknown"
619
+ else:
620
+ stats["vec_unavailable_reason"] = ""
603
621
  return stats
604
622
 
605
623
 
@@ -722,11 +740,47 @@ def _get_persona_manager():
722
740
 
723
741
  @app.get("/api/personas")
724
742
  def personas_list():
743
+ """PR73 v2.91.0 — merges JSON-store personas with the Obsidian
744
+ vault's ``<vaultPath>/Personas/*.md`` files. Obsidian is the
745
+ source of truth: when the same persona exists in both places
746
+ (matched by name/slug), the vault entry wins.
747
+ """
748
+ by_id: dict[str, dict] = {}
749
+
750
+ # JSON store first (in-memory copy is fast, vault read may be slow)
725
751
  mgr = _get_persona_manager()
726
- if not mgr:
727
- return {"personas": [], "total": 0}
728
- personas = mgr.list_all()
729
- return {"personas": [p.model_dump() for p in personas], "total": len(personas)}
752
+ if mgr:
753
+ for p in mgr.list_all():
754
+ payload = p.model_dump()
755
+ by_id[payload["id"]] = payload
756
+
757
+ # Obsidian vault — overwrites duplicates so the vault wins.
758
+ try:
759
+ from core.personas.obsidian_store import ObsidianPersonaStore
760
+ ob_store = ObsidianPersonaStore()
761
+ if ob_store.available:
762
+ for p in ob_store.list_all():
763
+ payload = p.model_dump()
764
+ payload["_source_store"] = "obsidian"
765
+ by_id[payload["id"]] = payload
766
+ except Exception:
767
+ pass
768
+
769
+ personas = list(by_id.values())
770
+ personas.sort(key=lambda p: p.get("name", "").lower())
771
+ return {
772
+ "personas": personas,
773
+ "total": len(personas),
774
+ "obsidian_available": _obsidian_store_available(),
775
+ }
776
+
777
+
778
+ def _obsidian_store_available() -> bool:
779
+ try:
780
+ from core.personas.obsidian_store import ObsidianPersonaStore
781
+ return ObsidianPersonaStore().available
782
+ except Exception:
783
+ return False
730
784
 
731
785
 
732
786
  @app.get("/api/personas/{persona_id}")
@@ -772,7 +826,23 @@ def persona_create(body: dict):
772
826
  )
773
827
 
774
828
  mgr.create(persona)
775
- return {"id": persona.id, "created": True}
829
+
830
+ # PR73 v2.91.0 — also write to the Obsidian vault so the persona
831
+ # survives outside the JSON store and is browsable in Obsidian.
832
+ # Best-effort: vault unavailable / write failure does not abort
833
+ # the JSON-side success.
834
+ obsidian_path: str | None = None
835
+ try:
836
+ from core.personas.obsidian_store import ObsidianPersonaStore
837
+ ob_store = ObsidianPersonaStore()
838
+ if ob_store.available or ob_store._vault_path is not None:
839
+ written = ob_store.write(persona)
840
+ if written is not None:
841
+ obsidian_path = str(written)
842
+ except Exception:
843
+ pass
844
+
845
+ return {"id": persona.id, "created": True, "obsidian_path": obsidian_path}
776
846
 
777
847
 
778
848
  @app.post("/api/personas/{persona_id}/clone")