ltcai 11.0.1 → 11.1.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.
Files changed (95) hide show
  1. package/README.md +55 -43
  2. package/docs/CHANGELOG.md +28 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/PERFORMANCE.md +71 -18
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/embeddings.py +12 -37
  13. package/lattice_brain/graph/discovery_index.py +30 -32
  14. package/lattice_brain/graph/image_vectors.py +230 -0
  15. package/lattice_brain/graph/ingest.py +11 -5
  16. package/lattice_brain/graph/provenance.py +27 -2
  17. package/lattice_brain/graph/retrieval.py +113 -2
  18. package/lattice_brain/graph/retrieval_docgen.py +6 -6
  19. package/lattice_brain/graph/schema.py +9 -0
  20. package/lattice_brain/ingestion.py +209 -4
  21. package/lattice_brain/multimodal.py +738 -0
  22. package/lattice_brain/runtime/multi_agent.py +1 -1
  23. package/lattice_brain/self_model.py +77 -22
  24. package/latticeai/__init__.py +1 -1
  25. package/latticeai/api/memory.py +128 -1
  26. package/latticeai/core/agent.py +5 -1
  27. package/latticeai/core/agent_prompts.py +66 -0
  28. package/latticeai/core/context_builder.py +92 -8
  29. package/latticeai/core/embedding_providers.py +528 -0
  30. package/latticeai/core/legacy_compatibility.py +1 -1
  31. package/latticeai/core/marketplace.py +1 -1
  32. package/latticeai/core/messages.py +37 -0
  33. package/latticeai/core/workspace_os.py +43 -0
  34. package/latticeai/core/workspace_os_constants.py +1 -1
  35. package/latticeai/core/workspace_reorganization.py +335 -0
  36. package/latticeai/runtime/build_phases.py +5 -2
  37. package/latticeai/runtime/persistence_runtime.py +41 -4
  38. package/latticeai/runtime/runtime_context.py +1 -0
  39. package/latticeai/services/architecture_readiness.py +1 -1
  40. package/latticeai/services/change_proposals.py +50 -10
  41. package/latticeai/services/memory_service.py +34 -0
  42. package/latticeai/services/multimodal_ports.py +87 -0
  43. package/latticeai/services/product_readiness.py +1 -1
  44. package/latticeai/services/self_model_service.py +171 -0
  45. package/latticeai/services/voice_capture.py +27 -1
  46. package/package.json +1 -1
  47. package/scripts/check_current_release_docs.mjs +1 -1
  48. package/scripts/release_screen_claims.json +9 -0
  49. package/src-tauri/Cargo.lock +1 -1
  50. package/src-tauri/Cargo.toml +1 -1
  51. package/src-tauri/tauri.conf.json +1 -1
  52. package/static/app/asset-manifest.json +37 -37
  53. package/static/app/assets/{Act-D4zSxFR-.js → Act-D0HWqtn0.js} +1 -1
  54. package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D-QDW-A4.js} +1 -1
  55. package/static/app/assets/{Brain-C2EqQg74.js → Brain-CzCsI1mi.js} +1 -1
  56. package/static/app/assets/BrainHome-Btns-_TA.js +2 -0
  57. package/static/app/assets/BrainSignals-2dHQNkns.js +1 -0
  58. package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CT8v1StE.js} +1 -1
  59. package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DoLXC2KH.js} +1 -1
  60. package/static/app/assets/{Library-BN0HYOfc.js → Library-DDoxFE5c.js} +1 -1
  61. package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-BXMWIK_2.js} +1 -1
  62. package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-DOYf7JIs.js} +1 -1
  63. package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-COQsqidK.js} +1 -1
  64. package/static/app/assets/{System-BElUcSSw.js → System-BRllvYXd.js} +1 -1
  65. package/static/app/assets/arrow-left-DnyMzss-.js +1 -0
  66. package/static/app/assets/{bot--qYHMtkP.js → bot-4BvN07ux.js} +1 -1
  67. package/static/app/assets/brain-uMb_5hnO.js +1 -0
  68. package/static/app/assets/{button-51Z3rsuv.js → button-CDjtnAoU.js} +1 -1
  69. package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-D_RMn7tp.js} +1 -1
  70. package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-B5OpB8ae.js} +1 -1
  71. package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-BIlWInHf.js} +1 -1
  72. package/static/app/assets/{download-G-2olkWz.js → download-BtjXfL3z.js} +1 -1
  73. package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-DefMpxI2.js} +1 -1
  74. package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-BQ8NZVkw.js} +1 -1
  75. package/static/app/assets/{index-D7Rr-J2Y.js → index-0AvoEBzJ.js} +3 -3
  76. package/static/app/assets/index-vtEfYvQY.css +2 -0
  77. package/static/app/assets/{input-D4w_BZWl.js → input-B_5ZJ9oy.js} +1 -1
  78. package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-BqZ5tsgL.js} +1 -1
  79. package/static/app/assets/{primitives-d0g9pvzS.js → primitives-CVwew78r.js} +1 -1
  80. package/static/app/assets/search-DkhnOKZt.js +1 -0
  81. package/static/app/assets/{share-2-NmD7e_oV.js → share-2-D5zg_0fY.js} +1 -1
  82. package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-B5pZzkUb.js} +1 -1
  83. package/static/app/assets/{textarea-BPAJDc-0.js → textarea-nEVIweKY.js} +1 -1
  84. package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-Cm99AHlz.js} +1 -1
  85. package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-Dm__N6bL.js} +1 -1
  86. package/static/app/assets/{utils-DG1_ExrP.js → utils-DcDMoZIe.js} +1 -1
  87. package/static/app/assets/{workspace-CWVf3gsI.js → workspace-LtRRSKTf.js} +1 -1
  88. package/static/app/index.html +4 -4
  89. package/static/sw.js +1 -1
  90. package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
  91. package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
  92. package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
  93. package/static/app/assets/brain-DDCLjRqO.js +0 -1
  94. package/static/app/assets/index-CkzokZAj.css +0 -2
  95. package/static/app/assets/search-BLCYt75v.js +0 -1
@@ -49,7 +49,7 @@ from typing import Any, Callable, Dict, List, Optional
49
49
  from ..utils import now_iso as _now
50
50
  from .contracts import multi_agent_contract
51
51
 
52
- MULTI_AGENT_VERSION = "11.0.1"
52
+ MULTI_AGENT_VERSION = "11.1.0"
53
53
 
54
54
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
55
55
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -37,7 +37,7 @@ import hashlib
37
37
  import json
38
38
  import logging
39
39
  import re
40
- from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
40
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
41
41
 
42
42
  from .context import approx_tokens
43
43
  from .synthesis import ProposalDesk
@@ -127,7 +127,7 @@ _PATTERNS: Tuple[Tuple[str, "re.Pattern[str]", int, float, str], ...] = (
127
127
  (
128
128
  "preference",
129
129
  re.compile(
130
- r"(?:나는|저는|내가|제가)\s*(?P<v>[^\n.。]+?)"
130
+ r"(?:나는|저는|내가|제가)\s*(?P<v>[^\n.。]+?)\s*(?:을|를|이|가)?"
131
131
  r"\s*(?:좋아|선호|싫어)(?:합니다|한다|해요|해|하고)"
132
132
  ),
133
133
  1,
@@ -145,11 +145,10 @@ _PATTERNS: Tuple[Tuple[str, "re.Pattern[str]", int, float, str], ...] = (
145
145
  "en_first_person_preference",
146
146
  ),
147
147
  (
148
+ # The frequency word stays inside the capture: "회고를 씁니다" is a
149
+ # sentence, "매일 회고를 씁니다" is a habit.
148
150
  "habit",
149
- re.compile(
150
- r"(?:매일|매주|매달|아침마다|항상|늘)\s*(?P<v>[^\n.。]+?)"
151
- r"\s*(?:합니다|한다|해요|해)(?:\b|[\s.。!?]|$)"
152
- ),
151
+ re.compile(r"(?P<v>(?:매일|매주|매달|아침마다|항상|늘)\s*[^\n.。!?]+)"),
153
152
  1,
154
153
  0.65,
155
154
  "ko_routine",
@@ -157,7 +156,8 @@ _PATTERNS: Tuple[Tuple[str, "re.Pattern[str]", int, float, str], ...] = (
157
156
  (
158
157
  "habit",
159
158
  re.compile(
160
- r"\bI\s+(?:always|usually|every\s+(?:morning|day|week))\s+(?P<v>[^\n.!?]+)",
159
+ r"\bI\s+(?P<v>(?:always|usually|every\s+(?:morning|day|week))"
160
+ r"\s+[^\n.!?]+)",
161
161
  re.IGNORECASE,
162
162
  ),
163
163
  1,
@@ -205,7 +205,17 @@ _PATTERNS: Tuple[Tuple[str, "re.Pattern[str]", int, float, str], ...] = (
205
205
 
206
206
 
207
207
  class SelfModelError(ValueError):
208
- """Raised when a Self-Model operation cannot be carried out as asked."""
208
+ """Raised when a Self-Model operation cannot be carried out as asked.
209
+
210
+ Carries a machine-readable ``code`` alongside the developer message: Brain
211
+ Core cannot reach the server's message catalog (isolation), so the API
212
+ layer translates the code instead of forwarding an English sentence to a
213
+ Korean user.
214
+ """
215
+
216
+ def __init__(self, message: str, *, code: str = "invalid") -> None:
217
+ self.code = code
218
+ super().__init__(message)
209
219
 
210
220
 
211
221
  # ── extraction (deterministic) ───────────────────────────────────────────────
@@ -347,12 +357,18 @@ def apply_self_model_proposal(
347
357
  """
348
358
  item = review_queue.get(item_id, workspace_id=workspace_id)
349
359
  if str(item.get("kind")) != SELF_MODEL_KIND:
350
- raise SelfModelError(f"review item {item_id} is not a Self-Model proposal")
360
+ raise SelfModelError(
361
+ f"review item {item_id} is not a Self-Model proposal",
362
+ code="not_a_proposal",
363
+ )
351
364
  fact = dict((item.get("payload") or {}).get("fact") or {})
352
365
  kind = str(fact.get("kind") or "")
353
366
  text = _normalize(str(fact.get("text") or ""))
354
367
  if kind not in FACT_NODE_TYPES or not text:
355
- raise SelfModelError(f"review item {item_id} carries no Self-Model fact")
368
+ raise SelfModelError(
369
+ f"review item {item_id} carries no Self-Model fact",
370
+ code="empty_proposal",
371
+ )
356
372
  approved = review_queue.approve(item_id, workspace_id=workspace_id)
357
373
  node = _write_fact(
358
374
  store,
@@ -386,9 +402,11 @@ def upsert_self_model_fact(
386
402
  kind = str(kind or "").strip().lower()
387
403
  value = _normalize(text)
388
404
  if kind not in FACT_NODE_TYPES:
389
- raise SelfModelError(f"kind must be one of {sorted(FACT_NODE_TYPES)}")
405
+ raise SelfModelError(
406
+ f"kind must be one of {sorted(FACT_NODE_TYPES)}", code="invalid_kind"
407
+ )
390
408
  if not value:
391
- raise SelfModelError("a Self-Model fact needs text")
409
+ raise SelfModelError("a Self-Model fact needs text", code="text_required")
392
410
  return _write_fact(
393
411
  store,
394
412
  kind=kind,
@@ -405,14 +423,14 @@ def delete_self_model_fact(store: Any, node_id: str) -> Dict[str, Any]:
405
423
  """Remove one fact (or the whole root) from the subgraph, permanently."""
406
424
  identifier = str(node_id or "").strip()
407
425
  if not identifier.startswith(SELF_ID_PREFIX):
408
- raise SelfModelError(f"not a Self-Model node: {identifier}")
426
+ raise SelfModelError(f"not a Self-Model node: {identifier}", code="not_self_model")
409
427
  with store._connect() as conn:
410
428
  deleted = conn.execute(
411
429
  "DELETE FROM nodes WHERE id=?", (identifier,)
412
430
  ).rowcount
413
431
  conn.execute("DELETE FROM nodes_v2 WHERE id=?", (identifier,))
414
432
  if not deleted:
415
- raise SelfModelError(f"Self-Model node not found: {identifier}")
433
+ raise SelfModelError(f"Self-Model node not found: {identifier}", code="not_found")
416
434
  return {"status": "ok", "id": identifier, "deleted_at": now_iso()}
417
435
 
418
436
 
@@ -420,13 +438,18 @@ def delete_self_model_fact(store: Any, node_id: str) -> Dict[str, Any]:
420
438
 
421
439
 
422
440
  def list_self_model(
423
- store: Any, *, workspace_id: Optional[str] = None
441
+ store: Any,
442
+ *,
443
+ workspace_id: Optional[str] = None,
444
+ allowed_workspaces: Optional[Iterable[str]] = None,
424
445
  ) -> Dict[str, Any]:
425
446
  """Every fact the Brain holds about its owner, grouped for display."""
426
- facts = _read_facts(store, workspace_id=workspace_id)
447
+ facts = _read_facts(
448
+ store, workspace_id=workspace_id, allowed_workspaces=allowed_workspaces
449
+ )
427
450
  counts: Dict[str, int] = {kind: 0 for kind in KIND_ORDER}
428
451
  for fact in facts:
429
- counts[fact["kind"]] = counts.get(fact["kind"], 0) + 1
452
+ counts[fact["kind"]] += 1
430
453
  return {
431
454
  "available": True,
432
455
  "facts": facts,
@@ -442,6 +465,7 @@ def self_model_summary(
442
465
  *,
443
466
  limit_tokens: int = DEFAULT_SUMMARY_TOKENS,
444
467
  workspace_id: Optional[str] = None,
468
+ allowed_workspaces: Optional[Iterable[str]] = None,
445
469
  ) -> str:
446
470
  """Plain-text Self-Model summary for injection into a model's context.
447
471
 
@@ -450,7 +474,9 @@ def self_model_summary(
450
474
  An empty subgraph returns ``""`` — the caller injects nothing rather than a
451
475
  header with nothing under it.
452
476
  """
453
- facts = _read_facts(store, workspace_id=workspace_id)
477
+ facts = _read_facts(
478
+ store, workspace_id=workspace_id, allowed_workspaces=allowed_workspaces
479
+ )
454
480
  if not facts or limit_tokens <= 0:
455
481
  return ""
456
482
  header = "사용자에 대해 확인된 사실:"
@@ -523,8 +549,28 @@ def _write_fact(
523
549
  }
524
550
 
525
551
 
552
+ def _allowed_set(
553
+ workspace_id: Optional[str], allowed_workspaces: Optional[Iterable[str]]
554
+ ) -> Optional[set]:
555
+ """Normalize the two scoping spellings into one allow-set (``None`` = all).
556
+
557
+ ``allowed_workspaces`` is the scoping vocabulary the graph reads already
558
+ use (``filter_scoped_nodes``); ``workspace_id`` is the single-scope sugar
559
+ the API surfaces speak. A fact with no workspace is personal-global and
560
+ stays visible in both.
561
+ """
562
+ if allowed_workspaces is not None:
563
+ return {str(item) for item in allowed_workspaces if item}
564
+ if workspace_id is None:
565
+ return None
566
+ return {str(workspace_id)}
567
+
568
+
526
569
  def _read_facts(
527
- store: Any, *, workspace_id: Optional[str] = None
570
+ store: Any,
571
+ *,
572
+ workspace_id: Optional[str] = None,
573
+ allowed_workspaces: Optional[Iterable[str]] = None,
528
574
  ) -> List[Dict[str, Any]]:
529
575
  """Fact rows for a workspace, ordered deterministically (kind, then text).
530
576
 
@@ -533,6 +579,10 @@ def _read_facts(
533
579
  both read modes. A store that cannot be read yields no facts rather than
534
580
  raising into a prompt-assembly path.
535
581
  """
582
+ if not hasattr(store, "_connect"):
583
+ # Not a Brain store (a doc-generation seam may be handed a stand-in).
584
+ # There is no profile to read, and that is not an error worth logging.
585
+ return []
536
586
  try:
537
587
  with store._connect() as conn:
538
588
  rows = conn.execute(
@@ -544,11 +594,12 @@ def _read_facts(
544
594
  logger.exception("self-model read failed")
545
595
  return []
546
596
  facts = [fact for fact in (_row_to_fact(row) for row in rows) if fact]
547
- if workspace_id is not None:
597
+ allowed = _allowed_set(workspace_id, allowed_workspaces)
598
+ if allowed is not None:
548
599
  facts = [
549
600
  fact
550
601
  for fact in facts
551
- if fact["workspace_id"] in (None, "", str(workspace_id))
602
+ if not fact["workspace_id"] or str(fact["workspace_id"]) in allowed
552
603
  ]
553
604
  return sorted(facts, key=lambda fact: (KIND_ORDER.index(fact["kind"]), fact["text"]))
554
605
 
@@ -584,6 +635,7 @@ def summary_for_prompt(
584
635
  *,
585
636
  limit_tokens: int = DEFAULT_SUMMARY_TOKENS,
586
637
  workspace_id: Optional[str] = None,
638
+ allowed_workspaces: Optional[Iterable[str]] = None,
587
639
  ) -> str:
588
640
  """:func:`self_model_summary` that never raises — the injection seam.
589
641
 
@@ -592,7 +644,10 @@ def summary_for_prompt(
592
644
  """
593
645
  try:
594
646
  return self_model_summary(
595
- store, limit_tokens=limit_tokens, workspace_id=workspace_id
647
+ store,
648
+ limit_tokens=limit_tokens,
649
+ workspace_id=workspace_id,
650
+ allowed_workspaces=allowed_workspaces,
596
651
  )
597
652
  except Exception: # noqa: BLE001 — see docstring
598
653
  logger.exception("self-model summary failed")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "11.0.1"
3
+ __version__ = "11.1.0"
@@ -3,17 +3,26 @@
3
3
  Exposes :class:`~latticeai.services.memory_service.MemoryService` so the /app
4
4
  Memory view can inspect every memory tier, recall across them, and run manager
5
5
  operations (prune / compact / rebuild / clear). Full paths in decorators.
6
+
7
+ v11.1.0 adds the Self-Model surface (Track 4) on the same factory: the profile
8
+ the Brain holds about its owner is *memory*, so it is read, edited and deleted
9
+ here rather than behind a router of its own. Ownership is the whole point —
10
+ extraction only ever proposes (``/self-model/propose`` → Review Center →
11
+ ``/self-model/apply``), while the user's own edits and deletions write
12
+ directly.
6
13
  """
7
14
 
8
15
  from __future__ import annotations
9
16
 
10
- from typing import Callable, List, Optional
17
+ from typing import Any, Callable, List, Optional
11
18
 
12
19
  from fastapi import APIRouter, HTTPException, Request
13
20
  from pydantic import BaseModel
14
21
 
22
+ from lattice_brain.self_model import SelfModelError
15
23
  from latticeai.core.messages import http_error, resolve_language
16
24
  from latticeai.services.memory_service import MemoryService
25
+ from latticeai.services.self_model_service import SelfModelService
17
26
 
18
27
 
19
28
  class RecallRequest(BaseModel):
@@ -35,6 +44,49 @@ class ClearRequest(BaseModel):
35
44
  confirm: bool = False
36
45
 
37
46
 
47
+ #: Self-Model failure codes → catalog ids. Brain Core raises codes (it cannot
48
+ #: import the catalog); the answer a person reads is chosen here, in their
49
+ #: language. An unknown code degrades to the generic entry rather than leaking
50
+ #: an English developer sentence.
51
+ SELF_MODEL_MESSAGES = {
52
+ "invalid_kind": "self_model.invalid_kind",
53
+ "text_required": "self_model.text_required",
54
+ "not_found": "self_model.not_found",
55
+ "not_self_model": "self_model.not_self_model",
56
+ "not_a_proposal": "self_model.not_a_proposal",
57
+ "empty_proposal": "self_model.empty_proposal",
58
+ "graph_unavailable": "self_model.graph_unavailable",
59
+ "queue_unavailable": "self_model.queue_unavailable",
60
+ }
61
+
62
+
63
+ def _self_model_error(exc: SelfModelError, request: Request) -> HTTPException:
64
+ status = 404 if exc.code == "not_found" else 400
65
+ message_id = SELF_MODEL_MESSAGES.get(exc.code, "self_model.invalid")
66
+ return http_error(status, message_id, resolve_language(request))
67
+
68
+
69
+ class SelfModelFactRequest(BaseModel):
70
+ """A fact the user states about themselves (user-initiated direct write)."""
71
+
72
+ kind: str
73
+ text: str
74
+
75
+
76
+ class SelfModelProposeRequest(BaseModel):
77
+ """Text to read for candidate facts. Output is proposals, never writes."""
78
+
79
+ text: str = ""
80
+ source: str = ""
81
+ max_proposals: int = 5
82
+
83
+
84
+ class SelfModelApplyRequest(BaseModel):
85
+ """Approve one Self-Model proposal and write the fact it carries."""
86
+
87
+ item_id: str
88
+
89
+
38
90
  def create_memory_router(
39
91
  *,
40
92
  service: MemoryService,
@@ -44,8 +96,13 @@ def create_memory_router(
44
96
  gate_write: Callable[[Request], Optional[str]],
45
97
  append_audit_event: Callable[..., None],
46
98
  active_model_getter: Callable[[], str] | None = None,
99
+ self_model: Any = None,
47
100
  ) -> APIRouter:
48
101
  router = APIRouter()
102
+ # Derived from the memory service by default (graph + review queue), so the
103
+ # Self-Model routes exist on every build and report honestly when this
104
+ # Brain has no graph rather than disappearing from the API surface.
105
+ profile = self_model or SelfModelService(memory_service=service)
49
106
 
50
107
  @router.get("/api/memory/manager")
51
108
  async def memory_manager(request: Request):
@@ -140,4 +197,74 @@ def create_memory_router(
140
197
  append_audit_event("memory_clear", user_email=user, scope=req.scope)
141
198
  return result
142
199
 
200
+ # ── Self-Model (v11.1.0) ─────────────────────────────────────────────
201
+
202
+ @router.get("/api/memory/self-model")
203
+ async def self_model_profile(request: Request):
204
+ """What the Brain believes about its owner, and the injected summary."""
205
+ user = require_user(request)
206
+ scope = gate_read(request)
207
+ return profile.profile(user_email=user, workspace_id=scope)
208
+
209
+ @router.post("/api/memory/self-model")
210
+ async def self_model_upsert(req: SelfModelFactRequest, request: Request):
211
+ """Add or correct one fact directly — the user owns their profile."""
212
+ user = require_user(request)
213
+ scope = gate_write(request)
214
+ try:
215
+ fact = profile.upsert(kind=req.kind, text=req.text, workspace_id=scope)
216
+ except SelfModelError as exc:
217
+ raise _self_model_error(exc, request) from exc
218
+ append_audit_event(
219
+ "self_model_upsert", user_email=user, node_id=fact["id"], kind=fact["kind"]
220
+ )
221
+ return fact
222
+
223
+ @router.delete("/api/memory/self-model/{node_id}")
224
+ async def self_model_delete(node_id: str, request: Request):
225
+ """Forget one fact about the user, permanently."""
226
+ user = require_user(request)
227
+ gate_write(request)
228
+ try:
229
+ result = profile.delete(node_id)
230
+ except SelfModelError as exc:
231
+ raise _self_model_error(exc, request) from exc
232
+ append_audit_event("self_model_delete", user_email=user, node_id=node_id)
233
+ return result
234
+
235
+ @router.post("/api/memory/self-model/propose")
236
+ async def self_model_propose(req: SelfModelProposeRequest, request: Request):
237
+ """Read text for candidate facts; every hit becomes a review proposal."""
238
+ user = require_user(request)
239
+ scope = gate_write(request)
240
+ result = profile.propose(
241
+ req.text,
242
+ source=req.source or None,
243
+ user_email=user,
244
+ workspace_id=scope,
245
+ max_proposals=req.max_proposals,
246
+ )
247
+ append_audit_event(
248
+ "self_model_proposed",
249
+ user_email=user,
250
+ proposed=result.get("proposed_count", 0),
251
+ )
252
+ return result
253
+
254
+ @router.post("/api/memory/self-model/apply")
255
+ async def self_model_apply(req: SelfModelApplyRequest, request: Request):
256
+ """Approve a proposal and write the fact it carries into the graph."""
257
+ user = require_user(request)
258
+ scope = gate_write(request)
259
+ try:
260
+ result = profile.apply(req.item_id, workspace_id=scope)
261
+ except SelfModelError as exc:
262
+ raise _self_model_error(exc, request) from exc
263
+ except (KeyError, FileNotFoundError) as exc:
264
+ raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
265
+ append_audit_event(
266
+ "self_model_applied", user_email=user, item_id=req.item_id
267
+ )
268
+ return result
269
+
143
270
  return router
@@ -62,6 +62,7 @@ from latticeai.core.agent_permission import (
62
62
  resolve_deps_mode,
63
63
  )
64
64
  from latticeai.core.agent_profiles import AgentProfile, profile_for_model
65
+ from latticeai.core.agent_prompts import executor_prompt_for
65
66
 
66
67
  # The state vocabulary and the pure helpers live in sibling modules so this one
67
68
  # holds only the loop. They are re-exported (see ``__all__``) because callers —
@@ -646,7 +647,10 @@ class SingleAgentRuntime:
646
647
  + "\n".join(f"- {path}" for path in written)
647
648
  ) if written else ""
648
649
  return (
649
- f"{d.executor_prompt}\n\n"
650
+ # v11.1.0: the executor prompt carries profile-aware file-writing
651
+ # hints, because "wrote nothing at all" was the weak-model failure
652
+ # mode the loop could not repair after the fact.
653
+ f"{executor_prompt_for(d.executor_prompt, profile=profile)}\n\n"
650
654
  f"[LANGUAGE HINT: {lang_hint}]\n"
651
655
  f"Workspace root: {d.agent_root}{self._project_block(ctx)}\n\n"
652
656
  f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}{written_hint}\n\n"
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from typing import Any, Optional
6
+
5
7
  from latticeai.core.tool_registry import TOOL_CATALOG_BRIEF
6
8
 
7
9
  PLANNER_PROMPT = """You are the PLANNER role in Lattice AI's multi-role agent harness.
@@ -103,3 +105,67 @@ Rules:
103
105
 
104
106
 
105
107
  AGENT_SYSTEM_PROMPT = EXECUTOR_PROMPT
108
+
109
+
110
+ # ── file-writing hints (v11.1.0) ────────────────────────────────────────────
111
+ #
112
+ # A 1–4B local model does not fail at *writing a file*; it fails at deciding
113
+ # it is allowed to write one yet. The compact profile already gives that model
114
+ # a shorter transcript and an earlier escalation; these hints give it the two
115
+ # things the transcripts showed it missing most: a concrete order of
116
+ # operations, and permission to write the file on the very next turn.
117
+ #
118
+ # The strings are deliberately additive — ``EXECUTOR_PROMPT`` is untouched, so
119
+ # every caller that wants today's prompt keeps getting exactly today's prompt.
120
+
121
+ FILE_TASK_HINTS = """
122
+ FILE TASKS — how to actually finish one:
123
+ - If the request names a file to create, your FIRST action is write_file with
124
+ the complete content. Do not read, list, or plan around it first.
125
+ - args.path is a workspace-relative path (e.g. "report.md"), never absolute.
126
+ - args.content is the WHOLE file, not a fragment and not a description of it.
127
+ - After the write result appears in the transcript, verify once (read_file),
128
+ then finish with {"action": "final", ...}.
129
+ """
130
+
131
+ COMPACT_FILE_TASK_HINTS = """
132
+ FILE TASKS (short form):
133
+ 1. write_file {"path": "<name>", "content": "<the whole file>"}
134
+ 2. read_file to confirm it exists
135
+ 3. {"action": "final", "message": "..."}
136
+ Write the file on your next turn. Do not explain first.
137
+ """
138
+
139
+
140
+ def file_task_hints(profile: Any = None) -> str:
141
+ """The file-writing hints for a loop profile.
142
+
143
+ ``profile`` is an :class:`~latticeai.core.agent_profiles.AgentProfile`, its
144
+ name, or ``None``. The compact profile gets the numbered short form (small
145
+ models follow a list better than prose); everything else gets the standard
146
+ hints, so a strong model's prompt grows by four sentences and no rules.
147
+ """
148
+ name = getattr(profile, "name", profile)
149
+ if str(name or "").strip().lower() == "compact":
150
+ return COMPACT_FILE_TASK_HINTS.strip()
151
+ return FILE_TASK_HINTS.strip()
152
+
153
+
154
+ def executor_prompt_for(
155
+ base: str = EXECUTOR_PROMPT,
156
+ *,
157
+ profile: Any = None,
158
+ self_model_summary: Optional[str] = None,
159
+ ) -> str:
160
+ """``base`` plus the file hints, and the Self-Model summary when there is one.
161
+
162
+ The injection rule is the same one the context builder follows: an empty
163
+ (or missing) Self-Model adds nothing at all — no header, no placeholder —
164
+ so a Brain that knows nothing about its owner produces the prompt it
165
+ always produced.
166
+ """
167
+ parts = [str(base or "").rstrip(), file_task_hints(profile)]
168
+ summary = str(self_model_summary or "").strip()
169
+ if summary:
170
+ parts.append(f"ABOUT THE USER (from their Brain — honour it, do not restate it):\n{summary}")
171
+ return "\n\n".join(parts)
@@ -27,6 +27,7 @@ from typing import Any, Dict, List
27
27
 
28
28
  from lattice_brain.context import approx_tokens
29
29
  from lattice_brain.graph.retrieval import context_quality_signal
30
+ from lattice_brain.self_model import DEFAULT_SUMMARY_TOKENS, summary_for_prompt
30
31
 
31
32
  _CLEAN_RE = re.compile(r"\s+")
32
33
 
@@ -35,6 +36,20 @@ _CLEAN_RE = re.compile(r"\s+")
35
36
  # other on the same Brain.
36
37
  DEFAULT_DOCUMENT_CONTEXT_BUDGET = 2000
37
38
 
39
+ # ── Self-Model injection (v11.1.0) ──────────────────────────────────────────
40
+ # What the Brain knows about its owner rides along with the knowledge, so a
41
+ # generated document sounds like the person who asked for it. Three rules keep
42
+ # it from becoming another thing that can go wrong:
43
+ # 1. an empty Self-Model injects *nothing* — no header, no blank section;
44
+ # 2. the block never takes more than half the context budget, so the profile
45
+ # can never crowd out the knowledge the request is actually about;
46
+ # 3. the returned contract is unchanged — same keys, same
47
+ # ``context_quality`` shape, one extra trace section only when a block
48
+ # was really injected.
49
+ SELF_MODEL_SECTION_TITLE = "사용자 프로필"
50
+ SELF_MODEL_TRACE_SOURCE = "self_model"
51
+ SELF_MODEL_SECTION_HEADER = f"### 🙋 {SELF_MODEL_SECTION_TITLE}\n\n"
52
+
38
53
 
39
54
  def _clean(text: str, max_len: int = 700) -> str:
40
55
  return _CLEAN_RE.sub(" ", str(text or "")).strip()[:max_len]
@@ -72,6 +87,55 @@ def _fit_to_budget(context_md: str, budget: int) -> tuple:
72
87
  return head.rstrip(), True
73
88
 
74
89
 
90
+ def _self_model_budget(budget: int, limit_tokens: int) -> int:
91
+ """Tokens the profile block may use — never more than half the budget."""
92
+ if budget <= 0:
93
+ return limit_tokens
94
+ return min(limit_tokens, budget // 2)
95
+
96
+
97
+ def _self_model_block(
98
+ kg_store,
99
+ *,
100
+ enabled: bool,
101
+ budget: int,
102
+ limit_tokens: int,
103
+ allowed_workspaces,
104
+ ) -> str:
105
+ """Rendered profile section, or ``""`` when there is nothing to inject.
106
+
107
+ The section header is charged to the same allowance as the summary, so the
108
+ *rendered block* — not just its text — honours the ceiling.
109
+ """
110
+ allowance = _self_model_budget(budget, limit_tokens) - approx_tokens(
111
+ SELF_MODEL_SECTION_HEADER
112
+ )
113
+ if not enabled or allowance <= 0:
114
+ return ""
115
+ summary = summary_for_prompt(
116
+ kg_store, limit_tokens=allowance, allowed_workspaces=allowed_workspaces
117
+ )
118
+ if not summary:
119
+ return ""
120
+ return f"{SELF_MODEL_SECTION_HEADER}{summary}"
121
+
122
+
123
+ def _with_self_model(block: str, context_md: str) -> str:
124
+ """Prepend the profile block to rendered knowledge (either may be empty)."""
125
+ return "\n\n".join(part for part in (block, context_md) if part)
126
+
127
+
128
+ def _self_model_trace(block: str) -> List[Dict[str, Any]]:
129
+ if not block:
130
+ return []
131
+ return [{
132
+ "name": SELF_MODEL_SECTION_TITLE,
133
+ "source": SELF_MODEL_TRACE_SOURCE,
134
+ "approx_tokens": approx_tokens(block),
135
+ "provenance": [],
136
+ }]
137
+
138
+
75
139
  def retrieve_context_for_generation(
76
140
  kg_store,
77
141
  query: str,
@@ -81,6 +145,8 @@ def retrieve_context_for_generation(
81
145
  allowed_workspaces=None,
82
146
  include_legacy_global: bool = False,
83
147
  budget: int = DEFAULT_DOCUMENT_CONTEXT_BUDGET,
148
+ include_self_model: bool = True,
149
+ self_model_tokens: int = DEFAULT_SUMMARY_TOKENS,
84
150
  ) -> Dict[str, Any]:
85
151
  """Knowledge Graph에서 문서 생성에 필요한 컨텍스트를 검색·조합한다.
86
152
 
@@ -106,6 +172,21 @@ def retrieve_context_for_generation(
106
172
  if allowed_workspaces is not None
107
173
  else {}
108
174
  )
175
+ self_model_md = _self_model_block(
176
+ kg_store,
177
+ enabled=include_self_model,
178
+ budget=budget,
179
+ limit_tokens=self_model_tokens,
180
+ allowed_workspaces=allowed_workspaces,
181
+ )
182
+ # The profile's tokens come out of the same budget, so injecting it can
183
+ # never push the assembled context over the ceiling the caller asked for.
184
+ # The blank line that joins the two blocks is counted too — otherwise the
185
+ # assembled total lands one token over a tight budget.
186
+ knowledge_budget = budget
187
+ if self_model_md:
188
+ knowledge_budget -= approx_tokens(f"{self_model_md}\n\n")
189
+
109
190
  results = kg_store.search_for_document_generation(query, limit=max_results, **scope_kwargs)
110
191
  if not results:
111
192
  fallback_ctx = kg_store.context_for_query(
@@ -113,7 +194,7 @@ def retrieve_context_for_generation(
113
194
  limit=max_results,
114
195
  **scope_kwargs,
115
196
  )
116
- fallback_ctx, trimmed = _fit_to_budget(fallback_ctx or "", budget)
197
+ fallback_ctx, trimmed = _fit_to_budget(fallback_ctx or "", knowledge_budget)
117
198
  # Lexical fallback: the hybrid document search found nothing, so the
118
199
  # signal says lexical_only — exactly what chat reports in the same
119
200
  # situation, never a quiet downgrade.
@@ -121,16 +202,17 @@ def retrieve_context_for_generation(
121
202
  "lexical_only" if fallback_ctx else "none",
122
203
  1 if fallback_ctx else 0,
123
204
  )
205
+ assembled = _with_self_model(self_model_md, fallback_ctx)
124
206
  return {
125
207
  "query": query,
126
- "context_markdown": fallback_ctx,
208
+ "context_markdown": assembled,
127
209
  "sources": [],
128
210
  "stats": {"method": "fallback", "matches": 0, "budget_trimmed": trimmed},
129
211
  "context_quality": quality,
130
212
  "trace": {
131
213
  "budget_approx_tokens": budget,
132
- "used_approx_tokens": approx_tokens(fallback_ctx),
133
- "sections": [{
214
+ "used_approx_tokens": approx_tokens(assembled),
215
+ "sections": _self_model_trace(self_model_md) + [{
134
216
  "name": "Knowledge (fallback)",
135
217
  "source": "knowledge",
136
218
  "approx_tokens": approx_tokens(fallback_ctx),
@@ -154,11 +236,12 @@ def retrieve_context_for_generation(
154
236
  sections = _build_context_sections(results, extra_nodes_by_id, hop_data.get("edges", []))
155
237
  context_md = _render_markdown(query, sections)
156
238
  sources = _extract_sources(results)
157
- context_md, trimmed = _fit_to_budget(context_md, budget)
239
+ context_md, trimmed = _fit_to_budget(context_md, knowledge_budget)
240
+ assembled = _with_self_model(self_model_md, context_md)
158
241
 
159
242
  return {
160
243
  "query": query,
161
- "context_markdown": context_md,
244
+ "context_markdown": assembled,
162
245
  "sources": sources,
163
246
  "stats": {
164
247
  "method": "hybrid",
@@ -171,8 +254,8 @@ def retrieve_context_for_generation(
171
254
  "context_quality": context_quality_signal("hybrid", len(results)),
172
255
  "trace": {
173
256
  "budget_approx_tokens": budget,
174
- "used_approx_tokens": approx_tokens(context_md),
175
- "sections": [
257
+ "used_approx_tokens": approx_tokens(assembled),
258
+ "sections": _self_model_trace(self_model_md) + [
176
259
  {
177
260
  "name": section["title"],
178
261
  "source": "knowledge",
@@ -199,6 +282,7 @@ def _build_context_sections(
199
282
 
200
283
  docs = [r for r in primary_results if r["type"] in (
201
284
  "Document", "File", "SlideDeck", "Spreadsheet", "CodeFile", "Image", "ImageText",
285
+ "Audio",
202
286
  )]
203
287
  if docs:
204
288
  sections.append({