Skip to content

Commit 22f7afb

Browse files
mios-devclaude
andcommitted
mios-compact + mios-knowledge-add: artifact session state as OWUI knowledge
Operator directive 2026-05-17: "make sure there's tools to compact all this and artifact it natively for OWUI knowledge/database". Pair of helpers + auto-attach so the agent gets RAG-able session memory without any operator-side curation: 1. mios-compact -- pulls recent state from 5 sources: * OWUI chats (last N user/agent pairs, last <since> window) * Daemon launch_verifier failures (false-success claims) * Daemon classify / refusal / suggestions snapshot * Recent hermes session decisions (via daemon state) * Git commits in the window (session work record) Then optionally compacts via the local CPU model into a structured brief: Headline / Operator asks / Agent actions / Failures / System state / Carry-forward. Output to /var/lib/mios/compacted/digest-<utc-iso>.md (never overwrites prior digests; mios-knowledge-add picks the newest). Flags: --since "12 hours ago" (date(1) string), --chats N, --no-llm (raw sections only), --stdout (no file write). 2. mios-knowledge-add -- registers any markdown file/dir into an OWUI Knowledge collection. Distinct from mios-owui-apply- knowledge (which is for FHS-sourced canonical docs); this one takes ARBITRARY operator/daemon-produced markdown. * Creates the collection on first call (default name: "MiOS Session Memory"), reuses on subsequent calls. * Inserts each file into the OWUI `file` table with sha256 hash, full content, meta.managed_by = "mios-knowledge-add" so mios-cache-clear preserves them. * Links each file into the collection via knowledge_file. * NEW --attach-to <model-id> (default: mios-agent + auto- includes mios_agent.mios-agent): binds the collection into the model row's meta.knowledge so the MiOS-Agent model RAGs over it automatically on every chat. Live-verified both model rows now show "MiOS Session Memory" in meta.knowledge with the right collection id. * --replace updates the same-name file in-place; default adds a new entry (so digest history accumulates). * --dry-run + --tag for operator workflow. Plumbing: * Both shims symlinked into /usr/local/{s,}bin via tmpfiles.d (shim count 39 -> 41). * tool-hints.yaml gains both verbs with intent + example so the refine layer hints them on operator asks like "save this session" / "remember what we did" / "compact this". * SOUL.md helpers table picks up both + mios-map. End-to-end live-tested: compact pulled 5h of dev-distro state + last 21 git commits, LLM compacted to a 2925-char digest, knowledge- add registered it into "MiOS Session Memory" (id=b4e854f0...), attached to both mios-agent and mios_agent.mios-agent model rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9123371 commit 22f7afb

5 files changed

Lines changed: 632 additions & 0 deletions

File tree

usr/lib/tmpfiles.d/mios-shim-links.conf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,7 @@ L+ /usr/local/bin/mios-map - - - - /usr/libexec/mios/mios-map
8989
L+ /usr/local/sbin/mios-map - - - - /usr/libexec/mios/mios-map
9090
L+ /usr/local/bin/mios-hermes-soul-sync - - - - /usr/libexec/mios/mios-hermes-soul-sync
9191
L+ /usr/local/sbin/mios-hermes-soul-sync - - - - /usr/libexec/mios/mios-hermes-soul-sync
92+
L+ /usr/local/bin/mios-compact - - - - /usr/libexec/mios/mios-compact
93+
L+ /usr/local/sbin/mios-compact - - - - /usr/libexec/mios/mios-compact
94+
L+ /usr/local/bin/mios-knowledge-add - - - - /usr/libexec/mios/mios-knowledge-add
95+
L+ /usr/local/sbin/mios-knowledge-add - - - - /usr/libexec/mios/mios-knowledge-add

usr/libexec/mios/mios-compact

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
#!/usr/bin/env python3
2+
"""mios-compact -- compact recent agent + system state into a single
3+
markdown digest that can be ingested as an OWUI knowledge artifact.
4+
5+
Operator directive 2026-05-17: "make sure there's tools to compact
6+
all this and artifact it natively for OWUI knowledge/database". The
7+
agent stack generates a lot of latent state (recent chats, hermes
8+
session decisions, daemon classifications, launch verifier failures,
9+
git commits) that's useful for the agent to RAG against on later
10+
turns. This helper pulls the lot, summarizes via the local CPU
11+
model, and writes a versioned markdown file under
12+
/var/lib/mios/compacted/. Pair with mios-knowledge-add to register
13+
the file as a Knowledge collection in OWUI.
14+
15+
Sections in the rendered digest:
16+
1. Recent operator chats (last N user turns + agent responses)
17+
2. Launch verifier failures (from daemon)
18+
3. Recent hermes tool-call patterns
19+
4. Daemon classify summaries (system log roll-up)
20+
5. Git commits this session
21+
22+
Output: /var/lib/mios/compacted/<utc-iso>.md (timestamped, never
23+
overwrites previous digests; mios-knowledge-add picks the newest).
24+
25+
Usage:
26+
mios-compact # default: last 24h of activity
27+
mios-compact --since "12 hours ago" # parseable by `date`
28+
mios-compact --chats 10 # cap chat count
29+
mios-compact --out <path> # override output path
30+
mios-compact --stdout # print to stdout instead of file
31+
mios-compact --no-llm # skip the CPU summarization step
32+
# (raw section dumps only)
33+
34+
Exit codes:
35+
0 = digest written (or printed)
36+
1 = required state unreachable (OWUI db missing, ollama down)
37+
64 = bad args
38+
"""
39+
from __future__ import annotations
40+
41+
import argparse
42+
import datetime
43+
import json
44+
import os
45+
import sqlite3
46+
import subprocess
47+
import sys
48+
import urllib.error
49+
import urllib.request
50+
from pathlib import Path
51+
52+
OWUI_DB = Path(os.environ.get(
53+
"MIOS_OWUI_DB", "/var/lib/mios/open-webui/webui.db"))
54+
DAEMON_STATE = Path(os.environ.get(
55+
"MIOS_DAEMON_STATE", "/var/lib/mios/daemon/state.json"))
56+
LAUNCH_FAILURES = Path(os.environ.get(
57+
"MIOS_DAEMON_LAUNCH_FAILURES", "/var/lib/mios/daemon/launch_failures.json"))
58+
COMPACTED_DIR = Path(os.environ.get(
59+
"MIOS_COMPACTED_DIR", "/var/lib/mios/compacted"))
60+
REPO_ROOT = Path(os.environ.get("MIOS_REPO_ROOT", "/mnt/c/MiOS"))
61+
OLLAMA_URL = os.environ.get("MIOS_OLLAMA_URL", "http://127.0.0.1:11434")
62+
SUMMARY_MODEL = os.environ.get("MIOS_COMPACT_MODEL", "qwen2.5-coder:7b")
63+
64+
65+
def _ts() -> str:
66+
return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H-%M-%SZ")
67+
68+
69+
def _since_to_epoch(since: str) -> int:
70+
"""Parse a date(1)-compatible time string. Returns epoch seconds."""
71+
try:
72+
out = subprocess.run(
73+
["date", "-d", since, "+%s"],
74+
capture_output=True, text=True, check=True, timeout=4,
75+
)
76+
return int(out.stdout.strip())
77+
except Exception:
78+
return int(datetime.datetime.utcnow().timestamp()) - 86400
79+
80+
81+
# --- 1. recent OWUI chats ----------------------------------------
82+
83+
def section_recent_chats(since_epoch: int, max_chats: int) -> str:
84+
if not OWUI_DB.is_file():
85+
return "_OWUI db not present at " + str(OWUI_DB) + "_"
86+
rows = []
87+
try:
88+
c = sqlite3.connect(str(OWUI_DB))
89+
rows = c.execute(
90+
"SELECT id, chat, updated_at FROM chat "
91+
"WHERE updated_at > ? ORDER BY updated_at DESC LIMIT ?",
92+
(since_epoch, max_chats),
93+
).fetchall()
94+
except Exception as e:
95+
return f"_chat scan failed: {type(e).__name__}: {e}_"
96+
if not rows:
97+
return "_no chats in window_"
98+
out = []
99+
for cid, raw, updated_at in rows:
100+
try:
101+
d = json.loads(raw or "{}")
102+
except Exception:
103+
continue
104+
title = (d.get("title") or "(untitled)")[:80]
105+
messages = d.get("messages") or []
106+
# Pair user+assistant pairs (last 3 of each)
107+
user_msgs = [m for m in messages if isinstance(m, dict)
108+
and m.get("role") == "user"][-3:]
109+
asst_msgs = [m for m in messages if isinstance(m, dict)
110+
and m.get("role") == "assistant"][-3:]
111+
out.append(f"### {title}")
112+
out.append(f"_chat_id: {cid[:12]}, updated: "
113+
f"{datetime.datetime.utcfromtimestamp(updated_at).isoformat()}Z_")
114+
for m in user_msgs:
115+
content = str(m.get("content") or "").strip()[:300]
116+
out.append(f"- **user**: {content}")
117+
for m in asst_msgs:
118+
content = str(m.get("content") or "").strip()
119+
# Strip <details> block (raw reasoning); keep operator-facing
120+
if "<details" in content:
121+
content = content.split("</details>", 1)[-1].strip()
122+
out.append(f"- **agent**: {content[:400]}")
123+
out.append("")
124+
return "\n".join(out)
125+
126+
127+
# --- 2. launch verifier failures ---------------------------------
128+
129+
def section_launch_failures() -> str:
130+
if not LAUNCH_FAILURES.is_file():
131+
return "_no launch_failures.json (mios-daemon launch_verifier not yet ticked)_"
132+
try:
133+
data = json.loads(LAUNCH_FAILURES.read_text())
134+
except Exception as e:
135+
return f"_failures file unreadable: {e}_"
136+
if not data:
137+
return "_no false-success launches recorded_"
138+
out = []
139+
for f in data[-20:]:
140+
out.append(f"- `{f.get('ts','?')}` app=**{f.get('app','?')}** "
141+
f"verdict=`{f.get('verifier_summary','?')}` "
142+
f"prompt: _{(f.get('user_prompt') or '')[:120]}_")
143+
return "\n".join(out)
144+
145+
146+
# --- 3. daemon classify summaries --------------------------------
147+
148+
def section_daemon_state() -> str:
149+
if not DAEMON_STATE.is_file():
150+
return "_no daemon state.json_"
151+
try:
152+
d = json.loads(DAEMON_STATE.read_text())
153+
except Exception as e:
154+
return f"_state.json unreadable: {e}_"
155+
out = []
156+
cls = d.get("classify") or {}
157+
if cls:
158+
out.append(f"- **classify** ({cls.get('severity','?')}): "
159+
f"{cls.get('summary','?')}")
160+
ref = d.get("refusal") or {}
161+
if ref:
162+
out.append(f"- **refusal**: model=`{ref.get('model','?')}` "
163+
f"phrase=_{(ref.get('phrase') or '')[:120]}_")
164+
sg = d.get("suggestions") or {}
165+
if sg:
166+
out.append(f"- **suggestions** ts={sg.get('ts','?')} "
167+
f"count={sg.get('count','?')}")
168+
lv = d.get("launch_verifier") or {}
169+
if lv:
170+
out.append(f"- **launch_verifier** ts={lv.get('ts','?')} "
171+
f"scanned={lv.get('claims_scanned',0)} "
172+
f"false_success={lv.get('false_success_count',0)}")
173+
return "\n".join(out) or "_state empty_"
174+
175+
176+
# --- 4. recent git commits (session work record) -----------------
177+
178+
def section_git_commits(since_epoch: int) -> str:
179+
if not (REPO_ROOT / ".git").is_dir():
180+
return f"_repo not at {REPO_ROOT}_"
181+
try:
182+
since_iso = datetime.datetime.utcfromtimestamp(since_epoch).isoformat()
183+
out = subprocess.run(
184+
["git", "-C", str(REPO_ROOT), "log",
185+
f"--since={since_iso}", "--oneline", "--no-decorate"],
186+
capture_output=True, text=True, timeout=8,
187+
)
188+
lines = out.stdout.strip().splitlines()
189+
if not lines:
190+
return "_no commits in window_"
191+
return "\n".join(f"- `{line}`" for line in lines[:30])
192+
except Exception as e:
193+
return f"_git query failed: {e}_"
194+
195+
196+
# --- LLM compaction ----------------------------------------------
197+
198+
def _llm_summarize(raw_digest: str, timeout_s: int = 90) -> str:
199+
"""Send the raw digest to the CPU model with a tight system
200+
prompt, return a compacted summary. Returns the raw digest on
201+
failure (best-effort)."""
202+
system = (
203+
"You compact a multi-section MiOS session digest into a "
204+
"structured markdown brief. Preserve every fact from the "
205+
"input. NO prose padding, NO 'In summary', NO 'I hope this "
206+
"helps'. Output sections in this order:\n"
207+
"1. Headline (1 line: most important state change since the window started)\n"
208+
"2. Operator asks (bulleted)\n"
209+
"3. Agent actions taken (bulleted; cite tool names)\n"
210+
"4. Failures + verifier verdicts (bulleted)\n"
211+
"5. System state snapshot (1-3 lines)\n"
212+
"6. Carry-forward (1-3 lines: open follow-ups or pending state)\n"
213+
"Mirror the operator's language."
214+
)
215+
payload = {
216+
"model": SUMMARY_MODEL,
217+
"messages": [
218+
{"role": "system", "content": system},
219+
{"role": "user", "content": raw_digest[:32000]},
220+
],
221+
"options": {
222+
"num_gpu": 0, "num_thread": 8,
223+
"num_predict": 700, "temperature": 0.0,
224+
},
225+
"stream": False, "keep_alive": -1,
226+
}
227+
try:
228+
req = urllib.request.Request(
229+
f"{OLLAMA_URL}/api/chat",
230+
data=json.dumps(payload).encode("utf-8"),
231+
headers={"Content-Type": "application/json"},
232+
method="POST",
233+
)
234+
with urllib.request.urlopen(req, timeout=timeout_s) as r:
235+
body = json.loads(r.read())
236+
msg = body.get("message") or {}
237+
out = (msg.get("content") or "").strip()
238+
if not out:
239+
out = (msg.get("thinking") or msg.get("reasoning") or "").strip()
240+
return out or raw_digest
241+
except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
242+
sys.stderr.write(f"[mios-compact] llm-summarize failed: {e}\n")
243+
return raw_digest
244+
245+
246+
# --- main ---------------------------------------------------------
247+
248+
def main() -> int:
249+
ap = argparse.ArgumentParser(prog="mios-compact",
250+
description=__doc__.splitlines()[0])
251+
ap.add_argument("--since", default="24 hours ago",
252+
help='date(1) string, e.g. "12 hours ago"')
253+
ap.add_argument("--chats", type=int, default=12,
254+
help="cap N most recent chats (default 12)")
255+
ap.add_argument("--out", default=None,
256+
help="explicit output path (default: timestamped)")
257+
ap.add_argument("--stdout", action="store_true",
258+
help="print to stdout instead of writing a file")
259+
ap.add_argument("--no-llm", action="store_true",
260+
help="skip the CPU summarization step")
261+
args = ap.parse_args()
262+
263+
since_epoch = _since_to_epoch(args.since)
264+
since_iso = datetime.datetime.utcfromtimestamp(since_epoch).isoformat()
265+
now_iso = datetime.datetime.utcnow().isoformat()
266+
267+
raw_sections = [
268+
f"# MiOS session digest",
269+
f"_window: {since_iso}Z → {now_iso}Z_",
270+
"",
271+
"## Recent operator chats",
272+
section_recent_chats(since_epoch, args.chats),
273+
"",
274+
"## Launch verifier failures",
275+
section_launch_failures(),
276+
"",
277+
"## Daemon state snapshot",
278+
section_daemon_state(),
279+
"",
280+
"## Git commits this window",
281+
section_git_commits(since_epoch),
282+
]
283+
raw_text = "\n".join(raw_sections)
284+
285+
if args.no_llm:
286+
out_text = raw_text
287+
else:
288+
compacted = _llm_summarize(raw_text)
289+
out_text = (
290+
f"# MiOS session digest (compacted)\n\n"
291+
f"_compacted by `{SUMMARY_MODEL}` at {now_iso}Z; "
292+
f"raw digest covers {since_iso}Z → {now_iso}Z._\n\n"
293+
f"{compacted}\n\n---\n\n"
294+
f"<details><summary>Raw section dumps (uncompacted)</summary>\n\n"
295+
f"{raw_text}\n\n</details>"
296+
)
297+
298+
if args.stdout:
299+
sys.stdout.write(out_text + "\n")
300+
return 0
301+
302+
COMPACTED_DIR.mkdir(parents=True, exist_ok=True)
303+
out_path = Path(args.out) if args.out else (
304+
COMPACTED_DIR / f"digest-{_ts()}.md")
305+
out_path.write_text(out_text, encoding="utf-8")
306+
try:
307+
os.chmod(out_path, 0o644)
308+
except Exception:
309+
pass
310+
print(f"[mios-compact] wrote {out_path} ({len(out_text)} chars)")
311+
return 0
312+
313+
314+
if __name__ == "__main__":
315+
sys.exit(main())

0 commit comments

Comments
 (0)