Skip to content

Commit a3aab12

Browse files
authored
Merge branch 'main' into docs/husk-sweep-upstream-marker
2 parents 5d87d72 + 9d48544 commit a3aab12

42 files changed

Lines changed: 3602 additions & 531 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 223 additions & 16 deletions
Large diffs are not rendered by default.

‎docs/api.md‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1070,8 +1070,32 @@ Manually trigger a registered OME strategy.
10701070

10711071
| Field | Type | Notes |
10721072
|---|---|---|
1073-
| `status` | `"ok" \| "timeout"` | Whether the strategy completed within the timeout |
1073+
| `status` | `"ok" \| "timeout" \| "not_dispatched"` | `ok` = every dispatched run settled — a dead-lettered run still counts as settled (see `runs[*].error`); `timeout` = at least one run had not settled when `timeout` elapsed; `not_dispatched` = no strategy was dispatched (see below) |
10741074
| `name` | `string` | Echoes the requested strategy name |
1075+
| `dispatched` | `int` | Number of strategy routes enqueued. `0` iff `status == "not_dispatched"` |
1076+
| `runs` | `list[RunSummary]` | One entry per strategy run *attempt*, not per dispatched route: `{run_id: string, status: string, error?: string}`. A strategy that retried before settling contributes multiple entries sharing one `event_id`. `status` is one of `running` / `success` / `failed` / `dead_letter` / `crashed`. Includes dead-lettered runs |
1077+
1078+
**`not_dispatched`** means every subscriber was rejected by one of the
1079+
four dispatch gates (`_routes_to` / `enabled` / `applies_to` /
1080+
`Counter`). The most common cause is forgetting `"force": true` on a
1081+
strategy that is `enabled=false` in `ome.toml` — e.g. triggering
1082+
`reflect_episodes` without `force` while it is disabled in config
1083+
returns `{"status": "not_dispatched", "dispatched": 0, "runs": []}`
1084+
instead of an error.
1085+
1086+
> `status: "ok"` means all dispatched strategy runs settled — including
1087+
> runs that dead-lettered (their errors are in `runs[*].error`). It does
1088+
> **not** mean the LanceDB index has caught up. Markdown is written
1089+
> synchronously; the index syncs asynchronously (see
1090+
> [Eventual consistency](#eventual-consistency)).
1091+
>
1092+
> If you need read-your-write semantics, poll `GET /health`'s
1093+
> `cascade.pending` field until it reads `0` on two consecutive samples
1094+
> (a single zero can be a false convergence — the watcher-input window
1095+
> can briefly report an empty queue between md write and enqueue).
1096+
1097+
See [docs/openapi.json](openapi.json) for the exact generated schema
1098+
(`TriggerResponse` / `RunSummary`) behind this table.
10751099

10761100
#### Errors
10771101

‎docs/openapi.json‎

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"info": {
44
"title": "everos",
55
"description": "md-first memory extraction framework",
6-
"version": "1.2.2"
6+
"version": "1.2.3"
77
},
88
"paths": {
99
"/health": {
@@ -222,7 +222,7 @@
222222
"ome"
223223
],
224224
"summary": "Trigger",
225-
"description": "Manually trigger a registered OME strategy and wait for completion.",
225+
"description": "Manually trigger a registered OME strategy and wait for its runs to\nsettle. Returns without waiting for the LanceDB index — the response\nreflects markdown state only; poll ``GET /health``'s ``cascade.pending``\nfor index convergence (two consecutive zero samples to guard against the\nwatcher-input window). See docs/api.md#eventual-consistency.",
226226
"operationId": "trigger_api_v1_ome_trigger_post",
227227
"requestBody": {
228228
"content": {
@@ -982,7 +982,7 @@
982982
"ome"
983983
],
984984
"summary": "Trigger",
985-
"description": "Manually trigger a registered OME strategy and wait for completion.",
985+
"description": "Manually trigger a registered OME strategy and wait for its runs to\nsettle. Returns without waiting for the LanceDB index — the response\nreflects markdown state only; poll ``GET /health``'s ``cascade.pending``\nfor index convergence (two consecutive zero samples to guard against the\nwatcher-input window). See docs/api.md#eventual-consistency.",
986986
"operationId": "trigger_api_v2_ome_trigger_post",
987987
"requestBody": {
988988
"content": {
@@ -3089,6 +3089,36 @@
30893089
],
30903090
"title": "MessageItemDTO"
30913091
},
3092+
"RunSummary": {
3093+
"properties": {
3094+
"run_id": {
3095+
"type": "string",
3096+
"title": "Run Id"
3097+
},
3098+
"status": {
3099+
"type": "string",
3100+
"title": "Status"
3101+
},
3102+
"error": {
3103+
"anyOf": [
3104+
{
3105+
"type": "string"
3106+
},
3107+
{
3108+
"type": "null"
3109+
}
3110+
],
3111+
"title": "Error"
3112+
}
3113+
},
3114+
"type": "object",
3115+
"required": [
3116+
"run_id",
3117+
"status"
3118+
],
3119+
"title": "RunSummary",
3120+
"description": "One strategy run within a trigger response."
3121+
},
30923122
"SearchAgentCaseItem": {
30933123
"properties": {
30943124
"id": {
@@ -4006,15 +4036,28 @@
40064036
"name": {
40074037
"type": "string",
40084038
"title": "Name"
4039+
},
4040+
"dispatched": {
4041+
"type": "integer",
4042+
"title": "Dispatched"
4043+
},
4044+
"runs": {
4045+
"items": {
4046+
"$ref": "#/components/schemas/RunSummary"
4047+
},
4048+
"type": "array",
4049+
"title": "Runs",
4050+
"default": []
40094051
}
40104052
},
40114053
"type": "object",
40124054
"required": [
40134055
"status",
4014-
"name"
4056+
"name",
4057+
"dispatched"
40154058
],
40164059
"title": "TriggerResponse",
4017-
"description": "Response body for ``POST /api/v2/ome/trigger``."
4060+
"description": "Response body for ``POST /api/v2/ome/trigger``.\n\n``status`` distinguishes three outcomes that were previously masked as\na single ``ok``:\n\n- ``ok``: at least one strategy was dispatched and all runs settled\n within ``timeout``. Individual run outcomes are in ``runs`` (a\n ``dead_letter`` there is still ``ok`` at this level — the strategy\n *ran*, it just failed permanently).\n- ``timeout``: at least one strategy was dispatched but the engine did\n not go idle within ``timeout``. Runs may be partially complete;\n poll ``GET /health`` for cascade convergence separately.\n- ``not_dispatched``: no strategy was dispatched — the subscriber was\n rejected by one of the dispatch gates (``_routes_to`` / ``enabled`` /\n ``applies_to`` / ``Counter``). Common cause: forgetting\n ``force=true`` on a strategy that is ``enabled=false`` in ome.toml."
40184061
},
40194062
"UnprocessedMessageDTO": {
40204063
"properties": {

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "everos"
3-
version = "1.2.2"
3+
version = "1.2.3"
44
description = "EverOS — local-first markdown memory framework for AI agents and user chats; lightweight, dev-friendly, small-team"
55
license = {text = "Apache-2.0"}
66
readme = "README.md"

‎src/everos/config/default_ome.toml‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,12 @@
2929
# Atomic fact extraction runs per memcell. Always enabled — search
3030
# depends on atomic facts for MaxSim retrieval.
3131

32-
# Foresight extraction (runs per memcell). Heavy LLM call — set
33-
# enabled = false to skip in evaluation / benchmark runs.
32+
# Foresight extraction (runs per memcell). Heavy LLM call.
33+
#
34+
# DISABLED BY DEFAULT since 1.2.3: nothing in EverOS reads foresights yet
35+
# — no search route surfaces them, no prompt slot consumes them — so
36+
# running it spends one LLM call per sender per memcell on write-only
37+
# data. Uncomment to opt in; it works on agent and chat input alike.
3438
# [strategies.extract_foresight]
3539
# enabled = true
3640

‎src/everos/core/persistence/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
# Frontmatter schema chassis
1717
BaseFrontmatter, UserScopedFrontmatter, AgentScopedFrontmatter,
1818
DailyLogPathMixin, SkillPathMixin,
19+
# Path safety
20+
sanitize_dirname,
1921
# Async SQLite (SQLModel / SA 2.0)
2022
create_system_engine, create_session_factory, session_scope,
2123
SQLModel, Field, Relationship, BaseTable, RepoBase,
@@ -49,6 +51,7 @@
4951
from .markdown import parse_frontmatter as parse_frontmatter
5052
from .markdown import parse_structured_entry as parse_structured_entry
5153
from .markdown import render_structured_entry as render_structured_entry
54+
from .markdown import sanitize_dirname as sanitize_dirname
5255
from .markdown import split_entries as split_entries
5356
from .memory_root import MemoryRoot as MemoryRoot
5457
from .memory_root import app_dir_name as app_dir_name
@@ -100,6 +103,7 @@
100103
"project_dir_name",
101104
"project_id_from_dir",
102105
"render_structured_entry",
106+
"sanitize_dirname",
103107
"session_scope",
104108
"split_entries",
105109
"touch",

‎src/everos/core/persistence/markdown/__init__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
KnowledgeScopedMixin, KnowledgeDocumentPathMixin,
2222
KnowledgeTopicPathMixin,
2323
)
24+
25+
External usage (path safety):
26+
from everos.core.persistence.markdown import sanitize_dirname
2427
"""
2528

2629
from .entries import Entry as Entry
@@ -42,6 +45,7 @@
4245
from .frontmatter import dump_frontmatter as dump_frontmatter
4346
from .frontmatter import parse_frontmatter as parse_frontmatter
4447
from .parsed import ParsedMarkdown as ParsedMarkdown
48+
from .path_safety import sanitize_dirname as sanitize_dirname
4549
from .reader import MarkdownReader as MarkdownReader
4650
from .writer import MarkdownWriter as MarkdownWriter
4751

@@ -66,5 +70,6 @@
6670
"parse_frontmatter",
6771
"parse_structured_entry",
6872
"render_structured_entry",
73+
"sanitize_dirname",
6974
"split_entries",
7075
]

‎src/everos/core/persistence/markdown/frontmatter.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
import yaml
3737
from pydantic import BaseModel, ConfigDict
3838

39+
from .path_safety import sanitize_dirname
40+
3941
# ── YAML helpers ────────────────────────────────────────────────────────
4042

4143
_DELIM = "---"
@@ -229,6 +231,15 @@ class AgentSkillFrontmatter(SkillPathMixin, AgentScopedFrontmatter):
229231
SKILL_DIR_PREFIX: ClassVar[str] = "skill_"
230232
SKILL_MAIN_FILENAME: ClassVar[str] = "SKILL.md"
231233
...
234+
235+
``skill_dir_name`` / ``sanitize_skill_name`` are the single
236+
sanitization point both ``AgentSkillWriter`` and ``AgentSkillReader``
237+
derive their ``skill_<name>`` directory segment from, and that
238+
``memory.strategies.extract_agent_skill._persist_skill`` uses to
239+
sanitize LLM-emitted ``skill_name`` *before* constructing
240+
``AgentSkillFrontmatter`` — ``skill_name`` is LLM output and must not
241+
reach the filesystem, or the frontmatter's traversal validator,
242+
unsanitized (CWE-22).
232243
"""
233244

234245
SKILLS_CONTAINER_NAME: ClassVar[str]
@@ -244,6 +255,83 @@ def path_glob(cls) -> str:
244255
f"{cls.SKILL_DIR_PREFIX}*/{cls.SKILL_MAIN_FILENAME}"
245256
)
246257

258+
@classmethod
259+
def sanitize_skill_name(cls, skill_name: str) -> str:
260+
"""Bare sanitized skill name (no ``skill_`` prefix).
261+
262+
The single sanitization point for a skill's ``name`` value itself —
263+
as opposed to :meth:`skill_dir_name`, which additionally prefixes
264+
it for the directory segment. Callers building
265+
``AgentSkillFrontmatter.name`` from LLM output (see
266+
``memory.strategies.extract_agent_skill._persist_skill``) route
267+
through this *before* constructing the frontmatter, so
268+
``frontmatter.name`` ends up byte-identical to the directory-derived
269+
name rather than merely idempotent-if-resanitized.
270+
271+
This is lossy: distinct raw names can collapse onto the same
272+
sanitized name. Dropped punctuation, space/underscore collapse, and
273+
the 50-character cap are the visible cases (``"fix django"`` and
274+
``"fix_django"`` both become ``"fix_django"``; ``"fix!django"`` and
275+
``"fixdjango"`` both become ``"fixdjango"``). The larger case is
276+
every combining mark: a combining mark alone is not ``\\w``, so it
277+
is stripped regardless of script, and two names that differ only in
278+
their marks collide — e.g. Devanagari ``"किताब"`` and ``"कताब"``
279+
both sanitize to ``"कतब"``; the same holds for Thai tone marks,
280+
Hebrew niqqud, and Arabic harakat.
281+
282+
Case is *not* folded, which makes the collision above
283+
filesystem-dependent rather than universal, and is the dimension an
284+
LLM varies most freely: ``"Fix Django"`` → ``"Fix_Django"`` and
285+
``"fix django"`` → ``"fix_django"`` are two distinct sanitized
286+
names, so they are two rows in LanceDB (a case-sensitive Python
287+
string key) but one directory on a case-insensitive filesystem —
288+
macOS APFS and Windows NTFS in their default configurations. That
289+
splits the invariant this seam otherwise maintains: the surviving
290+
``SKILL.md`` carries one of the two names in its frontmatter while
291+
the index still advertises both, so a search hit on the shadowed
292+
name resolves to the other skill's content. On a case-sensitive
293+
filesystem the same pair simply stays two independent skills.
294+
295+
Because ``AgentSkillWriter.write_main`` is a full-file replace and
296+
the LanceDB primary key is ``f"{agent_id}_{sanitized_name}"``, a
297+
collision means the later skill silently overwrites the earlier
298+
one — its accumulated ``source_case_ids``, ``maturity_score``, and
299+
body are lost, not merged.
300+
301+
This is deliberate, not an oversight — but not because a collision
302+
"usually reads as an intended update". ``_persist_skill`` sanitizes
303+
*before* constructing the frontmatter, so the LLM is shown the
304+
already-sanitized name in ``existing_relevant_skills``; when it
305+
then emits a raw name like ``"fix django"`` after having just been
306+
shown ``"fix_django"``, it has affirmatively treated them as two
307+
different skills, and the write silently merges them anyway.
308+
309+
What justifies accepting it is narrower: the two alternatives are
310+
both worse here. Detecting a collision and raising would
311+
reintroduce the dead-letter DoS this sanitizer was built to avoid —
312+
LLM output would again decide whether a run survives. Appending a
313+
disambiguating suffix is the real candidate and is left for a
314+
deliberate design pass, not dismissed: it does *not* break the
315+
``frontmatter.name`` ≡ directory-suffix identity (writing
316+
``"fix_django_2"`` into both keeps that intact), but it does need a
317+
collision probe on a path that currently touches no other skill,
318+
and a rule for the case-insensitive-filesystem variant above where
319+
the probe must compare case-folded while the key stays exact.
320+
"""
321+
return sanitize_dirname(skill_name, fallback="unnamed")
322+
323+
@classmethod
324+
def skill_dir_name(cls, skill_name: str) -> str:
325+
"""Sanitized ``skill_<name>`` directory segment (traversal-safe).
326+
327+
Idempotent in ``skill_name``: calling this again on an already
328+
sanitized name (e.g. one recovered by walking the directory tree)
329+
returns the same segment, so a reader deriving ``skill_name`` from
330+
the on-disk directory and a writer deriving it from raw LLM output
331+
land on the same path.
332+
"""
333+
return f"{cls.SKILL_DIR_PREFIX}{cls.sanitize_skill_name(skill_name)}"
334+
247335

248336
class ProfilePathMixin:
249337
"""Path strategy for single-file profile markdown.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""``sanitize_dirname`` — the single path-safety primitive for md directory names.
2+
3+
Several markdown layouts turn free-text into a filesystem path segment:
4+
knowledge document/category titles, and agent-skill names. Both sources are
5+
untrusted in the same way — knowledge titles come from parsed source
6+
documents, skill names come straight from LLM output — so a name containing
7+
``../`` or a path separator must never survive into a directory segment
8+
(CWE-22 path traversal).
9+
10+
This module is the one place that decision is made. Callers that need a
11+
filesystem-safe segment from a free-text string route through
12+
:func:`sanitize_dirname` rather than keeping a private regex copy — see
13+
``writers/knowledge_writer.py`` and
14+
:meth:`SkillPathMixin.skill_dir_name() <.frontmatter.SkillPathMixin.skill_dir_name>`.
15+
Some callers (``knowledge_writer.py``) concatenate the result directly under a
16+
shared directory with no per-caller prefix, so the guarantee below has to hold
17+
on its own, without relying on a prefix like ``skill_`` to absorb a degenerate
18+
result.
19+
20+
``sanitize_dirname`` is idempotent (``sanitize_dirname(sanitize_dirname(x),
21+
fb) == sanitize_dirname(x, fb)``): a name built by re-sanitizing an
22+
already-sanitized segment (e.g. one derived by walking the directory tree)
23+
lands on the same string as sanitizing the original raw name. That property
24+
is what lets a reader and a writer agree on a path even when one side has
25+
only the raw name and the other only the on-disk directory name.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import re
31+
import unicodedata
32+
33+
_MAX_DIRNAME_LEN = 50
34+
_SAFE_CHARS = re.compile(r"[^\w\-.]", re.UNICODE)
35+
_DEGENERATE = frozenset({"", ".", ".."})
36+
37+
38+
def sanitize_dirname(raw: str, fallback: str) -> str:
39+
"""Produce a safe directory/file name segment from free-text input.
40+
41+
* NFC-normalize first. For an ordinary decomposed (NFD) input — a base
42+
letter plus a combining mark, e.g. ``"e"`` + combining acute accent —
43+
this collapses to the precomposed form before the character filter
44+
runs, so the accent survives (a combining mark alone is not ``\\w``
45+
and would otherwise be silently stripped). This is best-effort, not a
46+
guarantee: for the ~1,082 Unicode *composition exclusion* codepoints
47+
(e.g. Devanagari ``क़``/``ख़``, U+0958/U+0959), NFC does the
48+
opposite — it *decomposes* an already-precomposed exclusion
49+
character, because recomposing it is explicitly excluded from the
50+
NFC algorithm, and the resulting combining mark is then stripped just
51+
the same. Normalizing here improves fidelity for the common case; it
52+
does not make every Unicode script round-trip losslessly.
53+
* Replace spaces with underscores.
54+
* Strip characters outside ``[a-zA-Z0-9_\\-.]`` (``\\w`` is Unicode-aware,
55+
so CJK and other non-ASCII scripts survive readably). Note that ``.``
56+
is a *safe* character, not stripped — a run of literal dots is a legal
57+
result of this step.
58+
* Truncate to 50 characters.
59+
* Fall back to *fallback* if the result is empty, ``"."``, or ``".."``.
60+
61+
Every path separator (``/``, ``\\``) is stripped by the character-class
62+
filter, so no separator survives and the result is always exactly one
63+
path component — it can never be split into multiple segments by a
64+
downstream ``Path(...) / result``. The fallback on ``""`` / ``"."`` /
65+
``".."`` closes the remaining gap: those are the only single components
66+
that resolve to *no new child* (``""`` and ``"."`` both mean "this same
67+
directory", ``".."`` means "its parent") rather than a genuinely new
68+
entry. With both guarantees together, ``Path(some_dir) / sanitize_dirname(raw, fb)``
69+
can never escape ``some_dir`` and never silently collapses back onto it
70+
or its parent — unconditionally, including for a caller with no
71+
additional prefix (like ``skill_``) protecting the segment.
72+
73+
This function is lossy and not injective: distinct inputs can sanitize
74+
to the same output (dropped characters, space/underscore collapse, and
75+
truncation are all many-to-one). A caller that needs distinct outputs
76+
for distinct inputs must disambiguate before or after calling this —
77+
the function itself makes no such guarantee.
78+
"""
79+
slug = unicodedata.normalize("NFC", raw)
80+
slug = slug.replace(" ", "_")
81+
slug = _SAFE_CHARS.sub("", slug)
82+
slug = slug[:_MAX_DIRNAME_LEN]
83+
return slug if slug not in _DEGENERATE else fallback

0 commit comments

Comments
 (0)