arkaos 2.90.0 → 2.92.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.92.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}
@@ -0,0 +1,508 @@
1
+ <script setup lang="ts">
2
+ // PR74 v2.92.0 — Persona detail + edit drawer.
3
+ //
4
+ // Click a persona card on the list → this drawer opens with every
5
+ // field visible. Toggle to Edit mode to mutate any field, then save
6
+ // via PUT /api/personas/{id} (writes to both JSON store + Obsidian).
7
+
8
+ import type { Persona } from '~/types'
9
+
10
+ interface DetailResponse extends Persona {
11
+ _source_store?: 'obsidian' | 'json'
12
+ _obsidian_path?: string
13
+ }
14
+
15
+ const props = defineProps<{
16
+ modelValue: boolean
17
+ personaId: string | null
18
+ }>()
19
+
20
+ const emit = defineEmits<{
21
+ (e: 'update:modelValue', value: boolean): void
22
+ (e: 'saved', persona: Persona): void
23
+ (e: 'deleted', personaId: string): void
24
+ }>()
25
+
26
+ const { apiBase } = useApi()
27
+ const toast = useToast()
28
+
29
+ const detail = ref<DetailResponse | null>(null)
30
+ const editing = ref(false)
31
+ const draft = ref<Persona | null>(null)
32
+ const saving = ref(false)
33
+ const deleting = ref(false)
34
+ const loading = ref(false)
35
+ const loadError = ref<string | null>(null)
36
+
37
+ watch(
38
+ () => [props.modelValue, props.personaId] as const,
39
+ async ([open, id]) => {
40
+ if (!open || !id) {
41
+ detail.value = null
42
+ editing.value = false
43
+ draft.value = null
44
+ loadError.value = null
45
+ return
46
+ }
47
+ await loadDetail(id)
48
+ },
49
+ )
50
+
51
+ async function loadDetail(id: string) {
52
+ loading.value = true
53
+ loadError.value = null
54
+ try {
55
+ const data = await $fetch<DetailResponse | { error: string }>(
56
+ `${apiBase}/api/personas/${id}`,
57
+ )
58
+ if ('error' in data && data.error) {
59
+ loadError.value = data.error
60
+ detail.value = null
61
+ } else {
62
+ detail.value = data as DetailResponse
63
+ }
64
+ } catch (err) {
65
+ loadError.value = err instanceof Error ? err.message : 'unknown error'
66
+ } finally {
67
+ loading.value = false
68
+ }
69
+ }
70
+
71
+ function startEdit() {
72
+ if (!detail.value) return
73
+ draft.value = JSON.parse(JSON.stringify(detail.value)) as Persona
74
+ editing.value = true
75
+ }
76
+
77
+ function cancelEdit() {
78
+ draft.value = null
79
+ editing.value = false
80
+ }
81
+
82
+ async function saveEdit() {
83
+ if (!draft.value || !props.personaId) return
84
+ saving.value = true
85
+ try {
86
+ const res = await $fetch<{
87
+ id: string
88
+ updated: boolean
89
+ json_written: boolean
90
+ obsidian_path: string | null
91
+ error?: string
92
+ }>(`${apiBase}/api/personas/${props.personaId}`, {
93
+ method: 'PUT',
94
+ body: draft.value,
95
+ })
96
+ if (res.error) throw new Error(res.error)
97
+ toast.add({
98
+ title: 'Persona saved',
99
+ description: res.obsidian_path
100
+ ? `Wrote ${res.obsidian_path.split('/').slice(-2).join('/')}`
101
+ : 'Saved to JSON store',
102
+ color: 'success',
103
+ })
104
+ emit('saved', draft.value)
105
+ detail.value = { ...detail.value, ...draft.value } as DetailResponse
106
+ editing.value = false
107
+ } catch (err) {
108
+ toast.add({
109
+ title: 'Save failed',
110
+ description: err instanceof Error ? err.message : 'unknown error',
111
+ color: 'error',
112
+ })
113
+ } finally {
114
+ saving.value = false
115
+ }
116
+ }
117
+
118
+ async function deletePersona() {
119
+ if (!props.personaId) return
120
+ if (typeof window === 'undefined') return
121
+ const ok = window.confirm(
122
+ `Delete persona "${detail.value?.name}"?\n\n`
123
+ + 'This removes it from the JSON store. The Obsidian file (if any) '
124
+ + 'is left in place — delete manually from Obsidian if you want it gone.',
125
+ )
126
+ if (!ok) return
127
+ deleting.value = true
128
+ try {
129
+ await $fetch(`${apiBase}/api/personas/${props.personaId}`, {
130
+ method: 'DELETE',
131
+ })
132
+ toast.add({
133
+ title: 'Persona deleted',
134
+ description: detail.value?.name ?? '',
135
+ color: 'success',
136
+ })
137
+ emit('deleted', props.personaId)
138
+ emit('update:modelValue', false)
139
+ } catch (err) {
140
+ toast.add({
141
+ title: 'Delete failed',
142
+ description: err instanceof Error ? err.message : 'unknown error',
143
+ color: 'error',
144
+ })
145
+ } finally {
146
+ deleting.value = false
147
+ }
148
+ }
149
+
150
+ function closeDrawer() {
151
+ if (editing.value && !saving.value) {
152
+ if (typeof window !== 'undefined'
153
+ && !window.confirm('Discard unsaved edits?')) {
154
+ return
155
+ }
156
+ }
157
+ cancelEdit()
158
+ emit('update:modelValue', false)
159
+ }
160
+
161
+ function listToCsv(list: string[] | undefined): string {
162
+ return (list ?? []).join(', ')
163
+ }
164
+
165
+ function csvToList(value: string): string[] {
166
+ return value.split(',').map((s) => s.trim()).filter(Boolean)
167
+ }
168
+
169
+ const mbtiOptions = [
170
+ 'INTJ', 'INTP', 'ENTJ', 'ENTP',
171
+ 'INFJ', 'INFP', 'ENFJ', 'ENFP',
172
+ 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ',
173
+ 'ISTP', 'ISFP', 'ESTP', 'ESFP',
174
+ ].map((t) => ({ label: t, value: t }))
175
+
176
+ const discOptions = [
177
+ { label: 'D — Dominance', value: 'D' },
178
+ { label: 'I — Influence', value: 'I' },
179
+ { label: 'S — Steadiness', value: 'S' },
180
+ { label: 'C — Conscientiousness', value: 'C' },
181
+ ]
182
+
183
+ const vocabOptions = [
184
+ { label: 'Lay (no jargon)', value: 'lay' },
185
+ { label: 'Specialist (industry terms)', value: 'specialist' },
186
+ { label: 'Expert (research-level)', value: 'expert' },
187
+ ]
188
+ </script>
189
+
190
+ <template>
191
+ <USlideover
192
+ :open="modelValue"
193
+ :ui="{ content: 'max-w-2xl w-full' }"
194
+ @update:open="(v) => v ? null : closeDrawer()"
195
+ >
196
+ <template #content>
197
+ <UCard
198
+ :ui="{
199
+ root: 'h-full flex flex-col rounded-none',
200
+ body: 'flex-1 overflow-y-auto',
201
+ }"
202
+ >
203
+ <template #header>
204
+ <div class="flex items-start justify-between gap-3">
205
+ <div class="min-w-0">
206
+ <h2 class="text-xl font-bold truncate">
207
+ {{ detail?.name ?? 'Persona' }}
208
+ </h2>
209
+ <div class="flex items-center gap-2 mt-1 flex-wrap">
210
+ <UBadge
211
+ v-if="detail?._source_store === 'obsidian'"
212
+ label="From Obsidian"
213
+ icon="i-lucide-file-text"
214
+ color="primary"
215
+ variant="subtle"
216
+ size="xs"
217
+ />
218
+ <UBadge
219
+ v-else-if="detail?._source_store === 'json'"
220
+ label="JSON store"
221
+ variant="outline"
222
+ size="xs"
223
+ />
224
+ <span
225
+ v-if="detail?._obsidian_path"
226
+ class="text-xs text-muted font-mono truncate"
227
+ :title="detail._obsidian_path"
228
+ >
229
+ {{ detail._obsidian_path.split('/').slice(-2).join('/') }}
230
+ </span>
231
+ </div>
232
+ </div>
233
+ <div class="flex items-center gap-1 shrink-0">
234
+ <UButton
235
+ v-if="!editing"
236
+ icon="i-lucide-pencil"
237
+ variant="ghost"
238
+ size="sm"
239
+ aria-label="Edit persona"
240
+ @click="startEdit"
241
+ />
242
+ <UButton
243
+ v-if="!editing"
244
+ icon="i-lucide-trash-2"
245
+ color="error"
246
+ variant="ghost"
247
+ size="sm"
248
+ :loading="deleting"
249
+ aria-label="Delete persona"
250
+ @click="deletePersona"
251
+ />
252
+ <UButton
253
+ icon="i-lucide-x"
254
+ variant="ghost"
255
+ size="sm"
256
+ aria-label="Close"
257
+ @click="closeDrawer"
258
+ />
259
+ </div>
260
+ </div>
261
+ </template>
262
+
263
+ <div v-if="loading" class="flex items-center justify-center py-12">
264
+ <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
265
+ </div>
266
+
267
+ <div v-else-if="loadError" class="flex flex-col items-center justify-center gap-3 py-12">
268
+ <UIcon name="i-lucide-alert-triangle" class="size-10 text-red-500" />
269
+ <p class="text-sm text-muted">{{ loadError }}</p>
270
+ </div>
271
+
272
+ <div v-else-if="detail && !editing" class="space-y-6">
273
+ <p v-if="detail.tagline" class="text-base italic text-muted">
274
+ "{{ detail.tagline }}"
275
+ </p>
276
+
277
+ <section v-if="detail.title || detail.source">
278
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Identity</h3>
279
+ <dl class="grid grid-cols-3 gap-2 text-sm">
280
+ <dt class="text-muted">Title</dt>
281
+ <dd class="col-span-2">{{ detail.title || '—' }}</dd>
282
+ <dt class="text-muted">Source</dt>
283
+ <dd class="col-span-2 font-mono text-xs">{{ detail.source || '—' }}</dd>
284
+ </dl>
285
+ </section>
286
+
287
+ <section>
288
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Behavioural DNA</h3>
289
+ <dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
290
+ <dt class="text-muted">MBTI</dt>
291
+ <dd>{{ detail.mbti || '—' }}</dd>
292
+ <dt class="text-muted">DISC</dt>
293
+ <dd>
294
+ {{ detail.disc?.primary || '—' }}{{ detail.disc?.secondary ? `/${detail.disc.secondary}` : '' }}
295
+ </dd>
296
+ <dt class="text-muted">Enneagram</dt>
297
+ <dd>
298
+ {{ detail.enneagram?.type ?? '—' }}w{{ detail.enneagram?.wing ?? '?' }}
299
+ </dd>
300
+ </dl>
301
+ <div class="mt-3 space-y-1.5">
302
+ <div
303
+ v-for="trait in ([
304
+ ['Openness', detail.big_five?.openness ?? 0],
305
+ ['Conscientiousness', detail.big_five?.conscientiousness ?? 0],
306
+ ['Extraversion', detail.big_five?.extraversion ?? 0],
307
+ ['Agreeableness', detail.big_five?.agreeableness ?? 0],
308
+ ['Neuroticism', detail.big_five?.neuroticism ?? 0],
309
+ ] as Array<[string, number]>)"
310
+ :key="trait[0]"
311
+ class="flex items-center gap-3"
312
+ >
313
+ <span class="text-xs text-muted w-36 shrink-0">{{ trait[0] }}</span>
314
+ <div class="flex-1 h-2 rounded-full bg-muted/15 overflow-hidden">
315
+ <div class="h-2 rounded-full bg-primary" :style="{ width: `${trait[1]}%` }" />
316
+ </div>
317
+ <span class="text-xs font-mono w-10 text-right">{{ trait[1] }}</span>
318
+ </div>
319
+ </div>
320
+ </section>
321
+
322
+ <section v-if="detail.mental_models?.length">
323
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
324
+ Mental models ({{ detail.mental_models.length }})
325
+ </h3>
326
+ <div class="flex flex-wrap gap-1.5">
327
+ <UBadge
328
+ v-for="m in detail.mental_models"
329
+ :key="m"
330
+ :label="m"
331
+ variant="outline"
332
+ size="xs"
333
+ />
334
+ </div>
335
+ </section>
336
+
337
+ <section v-if="detail.expertise_domains?.length">
338
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
339
+ Expertise ({{ detail.expertise_domains.length }})
340
+ </h3>
341
+ <div class="flex flex-wrap gap-1.5">
342
+ <UBadge
343
+ v-for="e in detail.expertise_domains"
344
+ :key="e"
345
+ :label="e"
346
+ variant="soft"
347
+ size="xs"
348
+ />
349
+ </div>
350
+ </section>
351
+
352
+ <section v-if="detail.frameworks?.length">
353
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
354
+ Frameworks ({{ detail.frameworks.length }})
355
+ </h3>
356
+ <div class="flex flex-wrap gap-1.5">
357
+ <UBadge
358
+ v-for="f in detail.frameworks"
359
+ :key="f"
360
+ :label="f"
361
+ variant="outline"
362
+ size="xs"
363
+ />
364
+ </div>
365
+ </section>
366
+
367
+ <section v-if="detail.key_quotes?.length">
368
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
369
+ Key quotes ({{ detail.key_quotes.length }})
370
+ </h3>
371
+ <ul class="space-y-2">
372
+ <li
373
+ v-for="q in detail.key_quotes"
374
+ :key="q"
375
+ class="text-sm italic text-muted border-l-2 border-primary/30 pl-3"
376
+ >
377
+ "{{ q }}"
378
+ </li>
379
+ </ul>
380
+ </section>
381
+
382
+ <section v-if="detail.communication">
383
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Communication</h3>
384
+ <dl class="grid grid-cols-3 gap-2 text-sm">
385
+ <dt class="text-muted">Tone</dt>
386
+ <dd class="col-span-2">{{ detail.communication.tone || '—' }}</dd>
387
+ <dt class="text-muted">Vocabulary</dt>
388
+ <dd class="col-span-2">{{ detail.communication.vocabulary_level || '—' }}</dd>
389
+ </dl>
390
+ </section>
391
+ </div>
392
+
393
+ <div v-else-if="draft" class="space-y-5">
394
+ <section class="space-y-3">
395
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Identity</h3>
396
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
397
+ <UFormField label="Name" required>
398
+ <UInput v-model="draft.name" class="w-full" />
399
+ </UFormField>
400
+ <UFormField label="Title">
401
+ <UInput v-model="draft.title" class="w-full" />
402
+ </UFormField>
403
+ <UFormField label="Source">
404
+ <UInput v-model="draft.source" class="w-full" />
405
+ </UFormField>
406
+ <UFormField label="Tagline">
407
+ <UInput v-model="draft.tagline" class="w-full" />
408
+ </UFormField>
409
+ </div>
410
+ </section>
411
+
412
+ <section class="space-y-3">
413
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Behavioural DNA</h3>
414
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-3">
415
+ <UFormField label="MBTI">
416
+ <USelect v-model="draft.mbti" :items="mbtiOptions" class="w-full" />
417
+ </UFormField>
418
+ <UFormField label="DISC primary">
419
+ <USelect v-model="draft.disc.primary" :items="discOptions" class="w-full" />
420
+ </UFormField>
421
+ <UFormField label="Enneagram">
422
+ <UInput v-model.number="draft.enneagram.type" type="number" :min="1" :max="9" class="w-full" />
423
+ </UFormField>
424
+ <UFormField label="Wing">
425
+ <UInput v-model.number="draft.enneagram.wing" type="number" :min="1" :max="9" class="w-full" />
426
+ </UFormField>
427
+ </div>
428
+ <div class="space-y-2">
429
+ <div
430
+ v-for="key in (['openness', 'conscientiousness', 'extraversion', 'agreeableness', 'neuroticism'] as const)"
431
+ :key="key"
432
+ class="flex items-center gap-3"
433
+ >
434
+ <label class="text-xs text-muted w-36 shrink-0 capitalize">{{ key }}</label>
435
+ <UInput
436
+ v-model.number="draft.big_five[key]"
437
+ type="number"
438
+ :min="0"
439
+ :max="100"
440
+ class="w-20"
441
+ />
442
+ <input
443
+ v-model.number="draft.big_five[key]"
444
+ type="range"
445
+ :min="0"
446
+ :max="100"
447
+ class="flex-1"
448
+ />
449
+ </div>
450
+ </div>
451
+ </section>
452
+
453
+ <section class="space-y-3">
454
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Knowledge</h3>
455
+ <UFormField label="Mental models" help="comma-separated">
456
+ <UInput
457
+ :model-value="listToCsv(draft.mental_models)"
458
+ @update:model-value="(v: string) => draft && (draft.mental_models = csvToList(v))"
459
+ class="w-full"
460
+ />
461
+ </UFormField>
462
+ <UFormField label="Expertise domains" help="comma-separated">
463
+ <UInput
464
+ :model-value="listToCsv(draft.expertise_domains)"
465
+ @update:model-value="(v: string) => draft && (draft.expertise_domains = csvToList(v))"
466
+ class="w-full"
467
+ />
468
+ </UFormField>
469
+ <UFormField label="Frameworks" help="comma-separated">
470
+ <UInput
471
+ :model-value="listToCsv(draft.frameworks)"
472
+ @update:model-value="(v: string) => draft && (draft.frameworks = csvToList(v))"
473
+ class="w-full"
474
+ />
475
+ </UFormField>
476
+ </section>
477
+
478
+ <section class="space-y-3">
479
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Communication</h3>
480
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
481
+ <UFormField label="Tone">
482
+ <UInput v-model="draft.communication.tone" class="w-full" />
483
+ </UFormField>
484
+ <UFormField label="Vocabulary level">
485
+ <USelect v-model="draft.communication.vocabulary_level" :items="vocabOptions" class="w-full" />
486
+ </UFormField>
487
+ </div>
488
+ </section>
489
+ </div>
490
+
491
+ <template #footer>
492
+ <div v-if="editing" class="flex justify-end gap-2">
493
+ <UButton label="Cancel" variant="ghost" :disabled="saving" @click="cancelEdit" />
494
+ <UButton
495
+ label="Save"
496
+ icon="i-lucide-check"
497
+ :loading="saving"
498
+ @click="saveEdit"
499
+ />
500
+ </div>
501
+ <p v-else class="text-xs text-muted text-right">
502
+ Click ✏️ to edit. Saves to JSON store + Obsidian vault when configured.
503
+ </p>
504
+ </template>
505
+ </UCard>
506
+ </template>
507
+ </USlideover>
508
+ </template>
@@ -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
 
@@ -9,6 +9,23 @@ const { data, status, error, refresh } = fetchApi<{ personas: Persona[]; total:
9
9
 
10
10
  const personas = computed(() => data.value?.personas ?? [])
11
11
 
12
+ // PR74 v2.92.0 — detail/edit drawer state
13
+ const detailOpen = ref(false)
14
+ const detailPersonaId = ref<string | null>(null)
15
+
16
+ function openDetail(persona: Persona) {
17
+ detailPersonaId.value = persona.id
18
+ detailOpen.value = true
19
+ }
20
+
21
+ async function onDetailSaved() {
22
+ await refresh()
23
+ }
24
+
25
+ async function onDetailDeleted(_id: string) {
26
+ await refresh()
27
+ }
28
+
12
29
  // --- Creation mode ---
13
30
  // PR62 v2.79.0 — three modes: list (default), wizard (AI builder), manual.
14
31
  // The wizard is the new primary path; manual stays as fallback for
@@ -517,12 +534,24 @@ function discColor(disc: string): string {
517
534
  />
518
535
  </div>
519
536
 
537
+ <!-- PR74 v2.92.0 — detail/edit drawer -->
538
+ <PersonaDetailDrawer
539
+ v-model="detailOpen"
540
+ :persona-id="detailPersonaId"
541
+ @saved="onDetailSaved"
542
+ @deleted="onDetailDeleted"
543
+ />
544
+
520
545
  <!-- Personas Grid -->
521
546
  <div v-if="personas.length" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
522
547
  <UCard
523
548
  v-for="persona in personas"
524
549
  :key="persona.id"
525
- class="group flex flex-col"
550
+ class="group flex flex-col cursor-pointer hover:border-primary/40 transition-colors"
551
+ role="button"
552
+ tabindex="0"
553
+ @click="openDetail(persona)"
554
+ @keydown.enter="openDetail(persona)"
526
555
  >
527
556
  <div class="flex flex-col gap-3 flex-1">
528
557
  <!-- Header -->
@@ -583,7 +612,7 @@ function discColor(disc: string): string {
583
612
  </div>
584
613
 
585
614
  <!-- Actions -->
586
- <div class="pt-3 mt-auto border-t border-default space-y-3">
615
+ <div class="pt-3 mt-auto border-t border-default space-y-3" @click.stop>
587
616
  <div class="flex gap-2">
588
617
  <UButton
589
618
  label="Clone to Agent"
@@ -591,7 +620,7 @@ function discColor(disc: string): string {
591
620
  size="sm"
592
621
  variant="solid"
593
622
  class="flex-1"
594
- @click="toggleClone(persona)"
623
+ @click.stop="toggleClone(persona)"
595
624
  />
596
625
  <UButton
597
626
  icon="i-lucide-trash-2"
@@ -600,7 +629,7 @@ function discColor(disc: string): string {
600
629
  color="error"
601
630
  :loading="deleting === persona.id"
602
631
  aria-label="Delete persona"
603
- @click="deletePersona(persona)"
632
+ @click.stop="deletePersona(persona)"
604
633
  />
605
634
  </div>
606
635
 
@@ -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.92.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.92.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,22 +740,163 @@ 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}")
733
787
  def persona_detail(persona_id: str):
788
+ """PR74 v2.92.0 — detail endpoint now checks the Obsidian vault
789
+ in addition to the JSON store, so vault-only personas (Alex
790
+ Hormozi, Naval, etc.) resolve correctly.
791
+ """
792
+ # Try Obsidian first — it's the source of truth on conflicts.
793
+ try:
794
+ from core.personas.obsidian_store import ObsidianPersonaStore
795
+ ob_store = ObsidianPersonaStore()
796
+ if ob_store.available:
797
+ for p in ob_store.list_all():
798
+ if p.id == persona_id:
799
+ payload = p.model_dump()
800
+ payload["_source_store"] = "obsidian"
801
+ payload["_obsidian_path"] = str(
802
+ ob_store.personas_dir / f"{p.name}.md"
803
+ )
804
+ return payload
805
+ except Exception:
806
+ pass
807
+
734
808
  mgr = _get_persona_manager()
735
809
  if not mgr:
736
810
  return {"error": "Persona manager unavailable"}
737
811
  p = mgr.get(persona_id)
738
812
  if not p:
739
813
  return {"error": "Persona not found"}
740
- return p.model_dump()
814
+ payload = p.model_dump()
815
+ payload["_source_store"] = "json"
816
+ return payload
817
+
818
+
819
+ @app.put("/api/personas/{persona_id}")
820
+ def persona_update(persona_id: str, body: dict):
821
+ """PR74 v2.92.0 — update an existing persona. Writes to both the
822
+ JSON store (when the persona exists there) and the Obsidian vault
823
+ (when configured). Best-effort: a vault write failure does not
824
+ abort the JSON-side success and vice versa.
825
+
826
+ The persona name can change; in that case the old Obsidian file
827
+ is left in place (operator can delete it manually) and a new one
828
+ is created with the updated name.
829
+ """
830
+ from core.personas.schema import (
831
+ Persona, PersonaDISC, PersonaEnneagram, PersonaBigFive, PersonaCommunication,
832
+ )
833
+
834
+ # Start from existing data so partial-update bodies don't wipe fields.
835
+ existing = persona_detail(persona_id)
836
+ if "error" in existing:
837
+ return existing
838
+ merged = {**existing, **{k: v for k, v in body.items() if v is not None}}
839
+
840
+ name = merged.get("name", "Unknown")
841
+ new_id = (
842
+ merged.get("id")
843
+ or name.lower().replace(" ", "-").replace(".", "")
844
+ )
845
+
846
+ updated = Persona(
847
+ id=new_id,
848
+ name=name,
849
+ title=merged.get("title", ""),
850
+ tagline=merged.get("tagline", ""),
851
+ source=merged.get("source", name),
852
+ disc=PersonaDISC(**(merged.get("disc", {}) or {})),
853
+ enneagram=PersonaEnneagram(**(merged.get("enneagram", {}) or {})),
854
+ big_five=PersonaBigFive(**(merged.get("big_five", {}) or {})),
855
+ mbti=merged.get("mbti", "INTJ"),
856
+ mental_models=merged.get("mental_models", []) or [],
857
+ expertise_domains=merged.get("expertise_domains", []) or [],
858
+ frameworks=merged.get("frameworks", []) or [],
859
+ key_quotes=merged.get("key_quotes", []) or [],
860
+ communication=PersonaCommunication(
861
+ **(merged.get("communication", {}) or {}),
862
+ ),
863
+ created_at=merged.get("created_at", ""),
864
+ )
865
+
866
+ # JSON store — only if the persona originally lived there.
867
+ json_written = False
868
+ if existing.get("_source_store") != "obsidian":
869
+ mgr = _get_persona_manager()
870
+ if mgr:
871
+ try:
872
+ mgr.update(persona_id, updated.model_dump())
873
+ json_written = True
874
+ except Exception:
875
+ # Fall through to create if update isn't supported.
876
+ try:
877
+ mgr.create(updated)
878
+ json_written = True
879
+ except Exception:
880
+ json_written = False
881
+
882
+ # Obsidian — always overwrite when vault is configured.
883
+ obsidian_path: str | None = None
884
+ try:
885
+ from core.personas.obsidian_store import ObsidianPersonaStore
886
+ ob_store = ObsidianPersonaStore()
887
+ if ob_store.available or ob_store._vault_path is not None:
888
+ written = ob_store.write(updated)
889
+ if written is not None:
890
+ obsidian_path = str(written)
891
+ except Exception:
892
+ pass
893
+
894
+ return {
895
+ "id": updated.id,
896
+ "updated": True,
897
+ "json_written": json_written,
898
+ "obsidian_path": obsidian_path,
899
+ }
741
900
 
742
901
 
743
902
  @app.post("/api/personas")
@@ -772,7 +931,23 @@ def persona_create(body: dict):
772
931
  )
773
932
 
774
933
  mgr.create(persona)
775
- return {"id": persona.id, "created": True}
934
+
935
+ # PR73 v2.91.0 — also write to the Obsidian vault so the persona
936
+ # survives outside the JSON store and is browsable in Obsidian.
937
+ # Best-effort: vault unavailable / write failure does not abort
938
+ # the JSON-side success.
939
+ obsidian_path: str | None = None
940
+ try:
941
+ from core.personas.obsidian_store import ObsidianPersonaStore
942
+ ob_store = ObsidianPersonaStore()
943
+ if ob_store.available or ob_store._vault_path is not None:
944
+ written = ob_store.write(persona)
945
+ if written is not None:
946
+ obsidian_path = str(written)
947
+ except Exception:
948
+ pass
949
+
950
+ return {"id": persona.id, "created": True, "obsidian_path": obsidian_path}
776
951
 
777
952
 
778
953
  @app.post("/api/personas/{persona_id}/clone")