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.
- package/README.md +55 -43
- package/docs/CHANGELOG.md +28 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/PERFORMANCE.md +71 -18
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/embeddings.py +12 -37
- package/lattice_brain/graph/discovery_index.py +30 -32
- package/lattice_brain/graph/image_vectors.py +230 -0
- package/lattice_brain/graph/ingest.py +11 -5
- package/lattice_brain/graph/provenance.py +27 -2
- package/lattice_brain/graph/retrieval.py +113 -2
- package/lattice_brain/graph/retrieval_docgen.py +6 -6
- package/lattice_brain/graph/schema.py +9 -0
- package/lattice_brain/ingestion.py +209 -4
- package/lattice_brain/multimodal.py +738 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/self_model.py +77 -22
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/memory.py +128 -1
- package/latticeai/core/agent.py +5 -1
- package/latticeai/core/agent_prompts.py +66 -0
- package/latticeai/core/context_builder.py +92 -8
- package/latticeai/core/embedding_providers.py +528 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +37 -0
- package/latticeai/core/workspace_os.py +43 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_reorganization.py +335 -0
- package/latticeai/runtime/build_phases.py +5 -2
- package/latticeai/runtime/persistence_runtime.py +41 -4
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/change_proposals.py +50 -10
- package/latticeai/services/memory_service.py +34 -0
- package/latticeai/services/multimodal_ports.py +87 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/self_model_service.py +171 -0
- package/latticeai/services/voice_capture.py +27 -1
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/release_screen_claims.json +9 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-D4zSxFR-.js → Act-D0HWqtn0.js} +1 -1
- package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D-QDW-A4.js} +1 -1
- package/static/app/assets/{Brain-C2EqQg74.js → Brain-CzCsI1mi.js} +1 -1
- package/static/app/assets/BrainHome-Btns-_TA.js +2 -0
- package/static/app/assets/BrainSignals-2dHQNkns.js +1 -0
- package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CT8v1StE.js} +1 -1
- package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DoLXC2KH.js} +1 -1
- package/static/app/assets/{Library-BN0HYOfc.js → Library-DDoxFE5c.js} +1 -1
- package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-BXMWIK_2.js} +1 -1
- package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-DOYf7JIs.js} +1 -1
- package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-COQsqidK.js} +1 -1
- package/static/app/assets/{System-BElUcSSw.js → System-BRllvYXd.js} +1 -1
- package/static/app/assets/arrow-left-DnyMzss-.js +1 -0
- package/static/app/assets/{bot--qYHMtkP.js → bot-4BvN07ux.js} +1 -1
- package/static/app/assets/brain-uMb_5hnO.js +1 -0
- package/static/app/assets/{button-51Z3rsuv.js → button-CDjtnAoU.js} +1 -1
- package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-D_RMn7tp.js} +1 -1
- package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-B5OpB8ae.js} +1 -1
- package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-BIlWInHf.js} +1 -1
- package/static/app/assets/{download-G-2olkWz.js → download-BtjXfL3z.js} +1 -1
- package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-DefMpxI2.js} +1 -1
- package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-BQ8NZVkw.js} +1 -1
- package/static/app/assets/{index-D7Rr-J2Y.js → index-0AvoEBzJ.js} +3 -3
- package/static/app/assets/index-vtEfYvQY.css +2 -0
- package/static/app/assets/{input-D4w_BZWl.js → input-B_5ZJ9oy.js} +1 -1
- package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-BqZ5tsgL.js} +1 -1
- package/static/app/assets/{primitives-d0g9pvzS.js → primitives-CVwew78r.js} +1 -1
- package/static/app/assets/search-DkhnOKZt.js +1 -0
- package/static/app/assets/{share-2-NmD7e_oV.js → share-2-D5zg_0fY.js} +1 -1
- package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-B5pZzkUb.js} +1 -1
- package/static/app/assets/{textarea-BPAJDc-0.js → textarea-nEVIweKY.js} +1 -1
- package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-Cm99AHlz.js} +1 -1
- package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-Dm__N6bL.js} +1 -1
- package/static/app/assets/{utils-DG1_ExrP.js → utils-DcDMoZIe.js} +1 -1
- package/static/app/assets/{workspace-CWVf3gsI.js → workspace-LtRRSKTf.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
- package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
- package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
- package/static/app/assets/brain-DDCLjRqO.js +0 -1
- package/static/app/assets/index-CkzokZAj.css +0 -2
- 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
|
|
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))
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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(
|
|
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"]]
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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"]
|
|
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,
|
|
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")
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/memory.py
CHANGED
|
@@ -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
|
package/latticeai/core/agent.py
CHANGED
|
@@ -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
|
-
|
|
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 "",
|
|
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":
|
|
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(
|
|
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,
|
|
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":
|
|
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(
|
|
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({
|