arkaos 3.4.0 → 3.6.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
- 3.4.0
1
+ 3.6.0
@@ -0,0 +1,111 @@
1
+ """AI-assisted single-string suggester (PR83c v3.5.0).
2
+
3
+ Sibling to `core/agents/field_suggester` but for fields that hold ONE
4
+ string value (not a list). Used by the ✨ Generate button next to
5
+ fields like `communication.tone`, `communication.preferred_format`,
6
+ and `communication.language`.
7
+
8
+ Where the list suggester APPENDS, this one REPLACES. The caller is
9
+ expected to swap the field's existing value with the result.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+
17
+ from core.runtime.llm_provider import LLMProvider, LLMUnavailable, get_llm_provider
18
+
19
+
20
+ _VALID_FIELDS: tuple[str, ...] = ("tone", "preferred_format", "language")
21
+
22
+ _SYSTEM = (
23
+ "You suggest concise single-string values for behavioural agent and "
24
+ "persona profile fields. Return ONLY the value as plain text — no "
25
+ "JSON, no fences, no quotes, no explanations."
26
+ )
27
+
28
+ _FIELD_HINTS: dict[str, str] = {
29
+ "tone": (
30
+ "Return 2-4 adjectives separated by commas describing the "
31
+ "profile's voice (e.g. 'Direct, analytical, crisp'). "
32
+ "Max 60 characters."
33
+ ),
34
+ "preferred_format": (
35
+ "Return a short noun phrase listing the formats this profile "
36
+ "prefers (e.g. 'Briefs, tables, ASCII diagrams'). Max 80 characters."
37
+ ),
38
+ "language": (
39
+ "Return a comma-separated list of IETF language tags this profile "
40
+ "writes in (e.g. 'en' or 'en, pt-PT'). Max 20 characters."
41
+ ),
42
+ }
43
+
44
+ _MAX_LEN: dict[str, int] = {"tone": 60, "preferred_format": 80, "language": 20}
45
+
46
+
47
+ class StringSuggestionError(RuntimeError):
48
+ """LLM produced unusable output or could not be reached."""
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class StringSuggestionResult:
53
+ value: str
54
+ provider_name: str
55
+
56
+
57
+ def suggest_string_field(
58
+ field: str,
59
+ context: dict,
60
+ *,
61
+ provider: LLMProvider | None = None,
62
+ ) -> StringSuggestionResult:
63
+ """Return an AI-suggested single-string value for `field`."""
64
+ if field not in _VALID_FIELDS:
65
+ raise StringSuggestionError(f"unknown field: {field!r}")
66
+ llm = provider or get_llm_provider()
67
+ prompt = _build_prompt(field, context)
68
+ try:
69
+ resp = llm.complete(prompt, max_tokens=200, system=_SYSTEM)
70
+ except LLMUnavailable as exc:
71
+ raise StringSuggestionError(str(exc)) from exc
72
+ value = _clean(resp.text, field)
73
+ if not value:
74
+ raise StringSuggestionError("LLM returned an empty value")
75
+ return StringSuggestionResult(value=value, provider_name=llm.name())
76
+
77
+
78
+ def _build_prompt(field: str, context: dict) -> str:
79
+ name = (context.get("name") or "").strip() or "the entity"
80
+ role = (context.get("role") or context.get("title") or "").strip()
81
+ department = (context.get("department") or "").strip()
82
+ current = (context.get("current") or "").strip()
83
+ lines = [f"Suggest a {field.replace('_', ' ')} for {name}."]
84
+ if role:
85
+ lines.append(f"Role: {role}.")
86
+ if department:
87
+ lines.append(f"Department: {department}.")
88
+ if current:
89
+ lines.append(f"Current value (to be replaced, do not repeat verbatim): {current}.")
90
+ lines.append(_FIELD_HINTS[field])
91
+ return "\n".join(lines)
92
+
93
+
94
+ def _clean(text: str, field: str) -> str:
95
+ """Strip fences, quotes, leading bullets and trim to the field's max."""
96
+ cleaned = text.strip()
97
+ cleaned = re.sub(r"^```(?:\w+)?\s*|\s*```$", "", cleaned, flags=re.MULTILINE)
98
+ cleaned = cleaned.strip()
99
+ # Remove surrounding quotes the LLM may add.
100
+ if len(cleaned) >= 2 and cleaned[0] in '"\'`' and cleaned[-1] == cleaned[0]:
101
+ cleaned = cleaned[1:-1].strip()
102
+ # Drop a leading bullet/numbering.
103
+ cleaned = re.sub(r"^[-*•·]\s+", "", cleaned)
104
+ cleaned = re.sub(r"^\d+[.)]\s+", "", cleaned)
105
+ # Single-line: collapse internal whitespace, drop trailing punctuation.
106
+ cleaned = " ".join(cleaned.split())
107
+ cleaned = cleaned.rstrip(".")
108
+ max_len = _MAX_LEN[field]
109
+ if len(cleaned) > max_len:
110
+ cleaned = cleaned[:max_len].rstrip(", ").rstrip()
111
+ return cleaned
@@ -115,6 +115,52 @@ function csvToList(value: string): string[] {
115
115
  type SuggestField = 'mental_models_primary' | 'frameworks' | 'expertise_domains' | 'communication_avoid'
116
116
  const suggestingField = ref<SuggestField | null>(null)
117
117
 
118
+ // PR83c v3.5.0 — single-string suggester.
119
+ type StringField = 'tone' | 'preferred_format'
120
+ const suggestingString = ref<StringField | null>(null)
121
+
122
+ async function suggestString(field: StringField) {
123
+ if (!draft.value || !props.agent) return
124
+ const current
125
+ = field === 'tone' ? draft.value.communication.tone : draft.value.communication.preferred_format
126
+ suggestingString.value = field
127
+ try {
128
+ const res = await $fetch<{ value: string, provider_name: string, error?: string }>(
129
+ `${apiBase}/api/agents/suggest-string`,
130
+ {
131
+ method: 'POST',
132
+ body: {
133
+ field,
134
+ context: {
135
+ name: props.agent.name,
136
+ role: props.agent.role,
137
+ department: props.agent.department,
138
+ current,
139
+ },
140
+ },
141
+ },
142
+ )
143
+ if (res.error) throw new Error(res.error)
144
+ if (field === 'tone') draft.value.communication.tone = res.value
145
+ else draft.value.communication.preferred_format = res.value
146
+ markDirty()
147
+ toast.add({
148
+ title: 'Generated',
149
+ description: `via ${res.provider_name}`,
150
+ color: 'success',
151
+ icon: 'i-lucide-sparkles',
152
+ })
153
+ } catch (err) {
154
+ toast.add({
155
+ title: 'Generate failed',
156
+ description: err instanceof Error ? err.message : 'unknown error',
157
+ color: 'error',
158
+ })
159
+ } finally {
160
+ suggestingString.value = null
161
+ }
162
+ }
163
+
118
164
  async function suggest(field: SuggestField) {
119
165
  if (!draft.value || !props.agent) return
120
166
  const backendField = field === 'mental_models_primary' ? 'mental_models' : field
@@ -424,6 +470,18 @@ const vocabOptions = [
424
470
  <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Communication</h3>
425
471
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
426
472
  <UFormField label="Tone">
473
+ <template #hint>
474
+ <UButton
475
+ label="Generate"
476
+ icon="i-lucide-sparkles"
477
+ size="xs"
478
+ color="primary"
479
+ variant="soft"
480
+ :loading="suggestingString === 'tone'"
481
+ :disabled="suggestingString !== null"
482
+ @click="suggestString('tone')"
483
+ />
484
+ </template>
427
485
  <UInput v-model="draft.communication.tone" class="w-full" @update:model-value="markDirty" />
428
486
  </UFormField>
429
487
  <UFormField label="Vocabulary level">
@@ -435,6 +493,18 @@ const vocabOptions = [
435
493
  />
436
494
  </UFormField>
437
495
  <UFormField label="Preferred format">
496
+ <template #hint>
497
+ <UButton
498
+ label="Generate"
499
+ icon="i-lucide-sparkles"
500
+ size="xs"
501
+ color="primary"
502
+ variant="soft"
503
+ :loading="suggestingString === 'preferred_format'"
504
+ :disabled="suggestingString !== null"
505
+ @click="suggestString('preferred_format')"
506
+ />
507
+ </template>
438
508
  <UInput v-model="draft.communication.preferred_format" class="w-full" @update:model-value="markDirty" />
439
509
  </UFormField>
440
510
  <UFormField label="Language">
@@ -27,6 +27,38 @@ const deptActivity = computed<ActivityRow | null>(() =>
27
27
  (activityData.value?.by_department?.[agent.value?.department ?? ''] ?? null),
28
28
  )
29
29
 
30
+ // PR83d v3.6.0 — activity strip (30d, dept-level + last_used + rank)
31
+ interface ActivityStrip {
32
+ period: string
33
+ department: string
34
+ calls: number
35
+ cost_usd: number | null
36
+ tokens_in: number
37
+ tokens_out: number
38
+ last_used: string | null
39
+ dept_rank: number | null
40
+ dept_count: number
41
+ }
42
+ const { data: activityStrip } = fetchApi<ActivityStrip>(
43
+ `/api/agents/${agentId}/activity-strip?period=month`,
44
+ )
45
+
46
+ function formatRelative(iso: string | null): string {
47
+ if (!iso) return 'never'
48
+ const ts = Date.parse(iso)
49
+ if (Number.isNaN(ts)) return 'never'
50
+ const diff = Date.now() - ts
51
+ const minutes = Math.floor(diff / 60_000)
52
+ if (minutes < 1) return 'just now'
53
+ if (minutes < 60) return `${minutes}m ago`
54
+ const hours = Math.floor(minutes / 60)
55
+ if (hours < 24) return `${hours}h ago`
56
+ const days = Math.floor(hours / 24)
57
+ if (days < 30) return `${days}d ago`
58
+ const months = Math.floor(days / 30)
59
+ return `${months}mo ago`
60
+ }
61
+
30
62
  // PR76 — edit drawer state
31
63
  const editOpen = ref(false)
32
64
 
@@ -291,6 +323,49 @@ function formatTokens(n: number): string {
291
323
  </div>
292
324
  </section>
293
325
 
326
+ <!-- ===== ACTIVITY STRIP (PR83d) ===== -->
327
+ <section
328
+ v-if="activityStrip"
329
+ class="rounded-xl border border-default bg-elevated/10 p-4"
330
+ >
331
+ <div class="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
332
+ <div class="flex items-center gap-2">
333
+ <UIcon name="i-lucide-activity" class="size-4 text-primary" />
334
+ <span class="font-semibold uppercase tracking-wide text-muted text-xs">
335
+ 30d activity (dept)
336
+ </span>
337
+ </div>
338
+ <div class="flex items-center gap-2">
339
+ <span class="text-muted">Calls</span>
340
+ <span class="font-mono font-semibold">{{ activityStrip.calls }}</span>
341
+ </div>
342
+ <div class="flex items-center gap-2">
343
+ <span class="text-muted">Cost</span>
344
+ <span class="font-mono font-semibold">{{ formatCost(activityStrip.cost_usd) }}</span>
345
+ </div>
346
+ <div class="flex items-center gap-2">
347
+ <span class="text-muted">Tokens</span>
348
+ <span class="font-mono">
349
+ {{ formatTokens(activityStrip.tokens_in) }} /
350
+ {{ formatTokens(activityStrip.tokens_out) }}
351
+ </span>
352
+ </div>
353
+ <div class="flex items-center gap-2">
354
+ <span class="text-muted">Last used</span>
355
+ <span class="font-mono">{{ formatRelative(activityStrip.last_used) }}</span>
356
+ </div>
357
+ <div v-if="activityStrip.dept_rank" class="flex items-center gap-2">
358
+ <span class="text-muted">Dept rank</span>
359
+ <UBadge
360
+ :label="`#${activityStrip.dept_rank} of ${activityStrip.dept_count}`"
361
+ :color="activityStrip.dept_rank <= 3 ? 'primary' : 'neutral'"
362
+ variant="subtle"
363
+ size="sm"
364
+ />
365
+ </div>
366
+ </div>
367
+ </section>
368
+
294
369
  <AgentEditDrawer
295
370
  v-model="editOpen"
296
371
  :agent="agent"
@@ -88,6 +88,56 @@ const draft = ref<AgentDraft>({
88
88
 
89
89
  const saving = ref(false)
90
90
 
91
+ // PR83c v3.5.0 — single-string suggester.
92
+ type StringField = 'tone' | 'preferred_format'
93
+ const suggestingString = ref<StringField | null>(null)
94
+
95
+ async function suggestString(field: StringField) {
96
+ if (!draft.value.name.trim() || !draft.value.role.trim()) {
97
+ toast.add({
98
+ title: 'Add a name and role first',
99
+ color: 'warning',
100
+ })
101
+ return
102
+ }
103
+ const current = field === 'tone' ? draft.value.comm_tone : draft.value.comm_format
104
+ suggestingString.value = field
105
+ try {
106
+ const res = await $fetch<{ value: string, provider_name: string, error?: string }>(
107
+ `${apiBase}/api/agents/suggest-string`,
108
+ {
109
+ method: 'POST',
110
+ body: {
111
+ field,
112
+ context: {
113
+ name: draft.value.name,
114
+ role: draft.value.role,
115
+ department: draft.value.department,
116
+ current,
117
+ },
118
+ },
119
+ },
120
+ )
121
+ if (res.error) throw new Error(res.error)
122
+ if (field === 'tone') draft.value.comm_tone = res.value
123
+ else draft.value.comm_format = res.value
124
+ toast.add({
125
+ title: 'Generated',
126
+ description: `via ${res.provider_name}`,
127
+ color: 'success',
128
+ icon: 'i-lucide-sparkles',
129
+ })
130
+ } catch (err) {
131
+ toast.add({
132
+ title: 'Generate failed',
133
+ description: err instanceof Error ? err.message : 'unknown error',
134
+ color: 'error',
135
+ })
136
+ } finally {
137
+ suggestingString.value = null
138
+ }
139
+ }
140
+
91
141
  // PR82b v3.1.0 — AI draft from description.
92
142
  const description = ref('')
93
143
  const drafting = ref(false)
@@ -544,12 +594,36 @@ const bigFiveKeys = ['openness', 'conscientiousness', 'extraversion', 'agreeable
544
594
  <h3 class="text-sm font-semibold uppercase tracking-wide text-muted">Communication</h3>
545
595
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
546
596
  <UFormField label="Tone">
597
+ <template #hint>
598
+ <UButton
599
+ label="Generate"
600
+ icon="i-lucide-sparkles"
601
+ size="xs"
602
+ color="primary"
603
+ variant="soft"
604
+ :loading="suggestingString === 'tone'"
605
+ :disabled="suggestingString !== null"
606
+ @click="suggestString('tone')"
607
+ />
608
+ </template>
547
609
  <UInput v-model="draft.comm_tone" class="w-full" placeholder="Analytical, calm" />
548
610
  </UFormField>
549
611
  <UFormField label="Vocabulary level">
550
612
  <USelect v-model="draft.comm_vocab" :items="vocabOptions" class="w-full" />
551
613
  </UFormField>
552
614
  <UFormField label="Preferred format">
615
+ <template #hint>
616
+ <UButton
617
+ label="Generate"
618
+ icon="i-lucide-sparkles"
619
+ size="xs"
620
+ color="primary"
621
+ variant="soft"
622
+ :loading="suggestingString === 'preferred_format'"
623
+ :disabled="suggestingString !== null"
624
+ @click="suggestString('preferred_format')"
625
+ />
626
+ </template>
553
627
  <UInput v-model="draft.comm_format" class="w-full" placeholder="Briefs, tables, charts" />
554
628
  </UFormField>
555
629
  <UFormField label="Language">
@@ -226,6 +226,48 @@ function csvToList(value: string): string[] {
226
226
  type SuggestField = 'mental_models' | 'frameworks' | 'expertise_domains' | 'communication_avoid' | 'key_quotes'
227
227
  const suggestingField = ref<SuggestField | null>(null)
228
228
 
229
+ // PR83c v3.5.0 — single-string suggester (tone for personas).
230
+ const suggestingString = ref<'tone' | null>(null)
231
+
232
+ async function suggestString(field: 'tone') {
233
+ if (!draft.value || !detail.value) return
234
+ const current = draft.value.communication.tone
235
+ suggestingString.value = field
236
+ try {
237
+ const res = await $fetch<{ value: string, provider_name: string, error?: string }>(
238
+ `${apiBase}/api/personas/suggest-string`,
239
+ {
240
+ method: 'POST',
241
+ body: {
242
+ field,
243
+ context: {
244
+ name: detail.value.name,
245
+ title: detail.value.title,
246
+ current,
247
+ },
248
+ },
249
+ },
250
+ )
251
+ if (res.error) throw new Error(res.error)
252
+ draft.value.communication.tone = res.value
253
+ markDirty()
254
+ toast.add({
255
+ title: 'Generated',
256
+ description: `via ${res.provider_name}`,
257
+ color: 'success',
258
+ icon: 'i-lucide-sparkles',
259
+ })
260
+ } catch (err) {
261
+ toast.add({
262
+ title: 'Generate failed',
263
+ description: err instanceof Error ? err.message : 'unknown error',
264
+ color: 'error',
265
+ })
266
+ } finally {
267
+ suggestingString.value = null
268
+ }
269
+ }
270
+
229
271
  async function suggest(field: SuggestField) {
230
272
  if (!draft.value || !detail.value) return
231
273
  const current
@@ -754,6 +796,18 @@ const vocabOptions = [
754
796
  <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Communication</h3>
755
797
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
756
798
  <UFormField label="Tone">
799
+ <template #hint>
800
+ <UButton
801
+ label="Generate"
802
+ icon="i-lucide-sparkles"
803
+ size="xs"
804
+ color="primary"
805
+ variant="soft"
806
+ :loading="suggestingString === 'tone'"
807
+ :disabled="suggestingString !== null"
808
+ @click="suggestString('tone')"
809
+ />
810
+ </template>
757
811
  <UInput v-model="draft.communication.tone" class="w-full" @update:model-value="markDirty" />
758
812
  </UFormField>
759
813
  <UFormField label="Vocabulary level">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.4.0",
3
+ "version": "3.6.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 = "3.4.0"
3
+ version = "3.6.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"}
@@ -218,6 +218,90 @@ def agents_activity(period: str = "week"):
218
218
  return {"by_department": out, "period": period}
219
219
 
220
220
 
221
+ @app.get("/api/agents/{agent_id}/activity-strip")
222
+ def agent_activity_strip(agent_id: str, period: str = "month"):
223
+ """PR83d v3.6.0 — compact activity payload for the agent hero strip.
224
+
225
+ Returns:
226
+ {
227
+ "period": "month",
228
+ "department": "<dept>",
229
+ "calls": <int>,
230
+ "cost_usd": <float|null>,
231
+ "tokens_in": <int>, "tokens_out": <int>,
232
+ "last_used": "<ISO ts>"|null,
233
+ "dept_rank": <1-based int>|null,
234
+ "dept_count": <int>
235
+ }
236
+
237
+ All values reflect the agent's DEPARTMENT (per-agent attribution
238
+ isn't tracked yet — see PR47 telemetry).
239
+ """
240
+ agents = _load_agents()
241
+ base = None
242
+ for a in agents:
243
+ if a.get("id") == agent_id:
244
+ base = dict(a)
245
+ break
246
+ if not base:
247
+ return {"error": "Agent not found"}
248
+ dept = base.get("department") or ""
249
+ try:
250
+ from core.runtime.llm_cost_telemetry import (
251
+ VALID_PERIODS,
252
+ _load_slice,
253
+ _period_cutoff,
254
+ summarise,
255
+ )
256
+ except Exception:
257
+ return {"error": "telemetry unavailable"}
258
+ if period not in VALID_PERIODS:
259
+ period = "month"
260
+
261
+ summary = summarise(period=period)
262
+ dept_costs: list[tuple[str, float]] = []
263
+ target_row: dict | None = None
264
+ for category, row in (summary.by_category or {}).items():
265
+ if not isinstance(category, str) or not category.startswith("subagent:"):
266
+ continue
267
+ cat_dept = category.split(":", 1)[1] or "unknown"
268
+ cost = row.get("total_cost_usd")
269
+ dept_costs.append((cat_dept, float(cost) if isinstance(cost, (int, float)) else 0.0))
270
+ if cat_dept == dept:
271
+ target_row = row
272
+
273
+ dept_costs.sort(key=lambda t: t[1], reverse=True)
274
+ dept_rank: Optional[int] = None
275
+ for idx, (d, _) in enumerate(dept_costs, start=1):
276
+ if d == dept:
277
+ dept_rank = idx
278
+ break
279
+
280
+ entries, _ = _load_slice(None, _period_cutoff(period, now=None))
281
+ last_used: Optional[str] = None
282
+ for entry in reversed(entries):
283
+ cat = entry.get("category") or ""
284
+ if isinstance(cat, str) and cat == f"subagent:{dept}":
285
+ last_used = entry.get("ts")
286
+ break
287
+
288
+ return {
289
+ "period": period,
290
+ "department": dept,
291
+ "calls": int(target_row.get("call_count", 0)) if target_row else 0,
292
+ "cost_usd": (
293
+ float(target_row.get("total_cost_usd"))
294
+ if target_row and isinstance(target_row.get("total_cost_usd"), (int, float))
295
+ else None
296
+ ),
297
+ "tokens_in": int(target_row.get("total_tokens_in", 0)) if target_row else 0,
298
+ "tokens_out": int(target_row.get("total_tokens_out", 0)) if target_row else 0,
299
+ "last_used": last_used,
300
+ "dept_rank": dept_rank,
301
+ "dept_count": len(dept_costs),
302
+ }
303
+
304
+
221
305
  @app.get("/api/agents/{agent_id}")
222
306
  def agent_detail(agent_id: str):
223
307
  """Get full agent detail including YAML data."""
@@ -1949,6 +2033,33 @@ def agents_draft(body: dict):
1949
2033
  return {"draft": res.draft, "provider_name": res.provider_name}
1950
2034
 
1951
2035
 
2036
+ # --- AI single-string suggester (PR83c v3.5.0) ---
2037
+
2038
+ @app.post("/api/agents/suggest-string")
2039
+ def agents_suggest_string(body: dict):
2040
+ """Suggest a single-string value (tone, preferred_format, language)."""
2041
+ return _do_string_suggest(body, source="agent")
2042
+
2043
+
2044
+ @app.post("/api/personas/suggest-string")
2045
+ def personas_suggest_string(body: dict):
2046
+ return _do_string_suggest(body, source="persona")
2047
+
2048
+
2049
+ def _do_string_suggest(body: dict, *, source: str) -> dict:
2050
+ from core.agents.string_suggester import (
2051
+ StringSuggestionError,
2052
+ suggest_string_field,
2053
+ )
2054
+ field = (body.get("field") or "").strip()
2055
+ context = body.get("context") or {}
2056
+ try:
2057
+ res = suggest_string_field(field, context)
2058
+ except StringSuggestionError as exc:
2059
+ return {"error": str(exc)}
2060
+ return {"value": res.value, "provider_name": res.provider_name, "source": source}
2061
+
2062
+
1952
2063
  # --- AI list-field suggester (PR81 v2.99.0) ---
1953
2064
 
1954
2065
  @app.post("/api/agents/suggest")