Skip to content

Commit a6d5046

Browse files
committed
feat: add web ui activity history
1 parent 35e23d3 commit a6d5046

5 files changed

Lines changed: 133 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 0.26.0 - 2026-03-15
4+
5+
- added in-session workflow history and recent download tracking to the local web UI
6+
- improved the web UI so repeated local operations are easier to review and continue
7+
38
## 0.25.0 - 2026-03-14
49

510
- added zip upload and direct bundle downloads in the local web UI

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "memory-migrate-plugin"
7-
version = "0.25.0"
7+
version = "0.26.0"
88
description = "Open-source toolkit for migrating memories across AI agent systems."
99
readme = "README.md"
1010
requires-python = ">=3.11"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__all__ = ["__version__"]
22

3-
__version__ = "0.25.0"
3+
__version__ = "0.26.0"

src/memory_migrate_plugin/serve.py

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323

2424
UI_WORKSPACE_ROOT = Path(tempfile.gettempdir()) / "agent-memory-bridge-ui"
2525
DOWNLOAD_REGISTRY: dict[str, Path] = {}
26+
ACTION_HISTORY: list[dict[str, Any]] = []
27+
MAX_ACTION_HISTORY = 12
28+
MAX_RECENT_DOWNLOADS = 10
2629

2730
HTML_PAGE = Template("""<!doctype html>
2831
<html lang="en">
@@ -171,6 +174,31 @@
171174
background: #fffdf8;
172175
border: 1px solid var(--line);
173176
}
177+
.panel-title {
178+
font-weight: 700;
179+
letter-spacing: 0.02em;
180+
}
181+
.history {
182+
display: grid;
183+
gap: 10px;
184+
padding: 14px;
185+
border-radius: 14px;
186+
background: #fffdf8;
187+
border: 1px solid var(--line);
188+
}
189+
.history-item {
190+
border-top: 1px solid rgba(216, 201, 177, 0.7);
191+
padding-top: 10px;
192+
display: grid;
193+
gap: 4px;
194+
color: var(--muted);
195+
font-size: 0.92rem;
196+
}
197+
.history-item:first-of-type {
198+
border-top: none;
199+
padding-top: 0;
200+
}
201+
.history-item strong { color: var(--ink); }
174202
.downloads a {
175203
color: var(--accent);
176204
font-weight: 700;
@@ -268,6 +296,7 @@
268296
<div class="card output">
269297
<div class="banner $status_class">$message</div>
270298
$download_links
299+
$history_panel
271300
<pre>$output</pre>
272301
<div class="tips">
273302
<div>Suggested first try: <code>detect</code> or <code>inspect</code> before <code>bundle</code>.</div>
@@ -348,6 +377,71 @@ def register_download(path: Path) -> dict[str, str]:
348377
}
349378

350379

380+
def _recent_downloads() -> list[dict[str, str]]:
381+
items: list[dict[str, str]] = []
382+
for token, path in list(DOWNLOAD_REGISTRY.items())[-MAX_RECENT_DOWNLOADS:][::-1]:
383+
if path.exists() and path.is_file():
384+
items.append({
385+
"token": token,
386+
"path": str(path),
387+
"filename": path.name,
388+
"url": f"/download?token={token}",
389+
})
390+
return items
391+
392+
393+
def record_action_history(
394+
action: str,
395+
ok: bool,
396+
input_path: str,
397+
source_format: str | None,
398+
target_format: str | None,
399+
output_path: str | None,
400+
message: str,
401+
downloads: list[dict[str, str]] | None = None,
402+
) -> None:
403+
entry = {
404+
"id": uuid.uuid4().hex[:8],
405+
"action": action,
406+
"ok": ok,
407+
"input_path": input_path,
408+
"source_format": source_format or "auto",
409+
"target_format": target_format or "-",
410+
"output_path": output_path or "-",
411+
"message": message,
412+
"downloads": list(downloads or []),
413+
}
414+
ACTION_HISTORY.append(entry)
415+
if len(ACTION_HISTORY) > MAX_ACTION_HISTORY:
416+
del ACTION_HISTORY[:-MAX_ACTION_HISTORY]
417+
418+
419+
def render_history_panel(history: list[dict[str, Any]] | None = None, recent_downloads: list[dict[str, str]] | None = None) -> str:
420+
history = history if history is not None else ACTION_HISTORY
421+
recent_downloads = recent_downloads if recent_downloads is not None else _recent_downloads()
422+
sections = ['<div class="history"><div class="panel-title">Recent Activity</div>']
423+
if not history:
424+
sections.append('<div class="history-item"><strong>No runs yet.</strong><div>Run a workflow to populate local session history.</div></div>')
425+
else:
426+
for item in history[::-1]:
427+
state = "ok" if item["ok"] else "error"
428+
sections.append(
429+
f'<div class="history-item"><strong>{_html_escape(item["action"])} ? {state}</strong>'
430+
f'<div>{_html_escape(item["message"])}</div>'
431+
f'<div>input: {_html_escape(item["input_path"])}</div>'
432+
f'<div>target: {_html_escape(item["target_format"])} ? output: {_html_escape(item["output_path"])}</div></div>'
433+
)
434+
if recent_downloads:
435+
sections.append('<div class="panel-title">Recent Downloads</div>')
436+
for item in recent_downloads:
437+
sections.append(
438+
f'<div class="history-item"><a href="{_html_escape(item["url"])}">{_html_escape(item["filename"])}</a>'
439+
f'<div>{_html_escape(item["path"])}</div></div>'
440+
)
441+
sections.append('</div>')
442+
return ''.join(sections)
443+
444+
351445
def render_download_links(downloads: list[dict[str, str]] | None) -> str:
352446
if not downloads:
353447
return ""
@@ -472,10 +566,12 @@ def _handle_run(self) -> None:
472566
status_class = "ok"
473567
downloads = result.get("downloads", [])
474568
output = json.dumps(result, indent=2, ensure_ascii=False)
569+
record_action_history(action, True, input_path, source_format, target_format, output_path, message, downloads)
475570
except Exception as exc:
476571
message = f"Action '{action}' failed: {exc}"
477572
status_class = "error"
478573
output = json.dumps({"ok": False, "action": action, "error": str(exc)}, indent=2, ensure_ascii=False)
574+
record_action_history(action, False, input_path, source_format, target_format, output_path, message, [])
479575
self._send_html(
480576
render_page(
481577
action=action,
@@ -508,16 +604,20 @@ def _handle_upload(self) -> None:
508604
try:
509605
payload = upload.file.read()
510606
saved = save_uploaded_zip(getattr(upload, "filename", "upload.zip"), payload)
607+
message = f"Uploaded and extracted zip into {saved['input_path']}"
608+
record_action_history("upload", True, saved["input_path"], None, None, saved["zip_path"], message, [])
511609
self._send_html(
512610
render_page(
513611
input_path=saved["input_path"],
514-
message=f"Uploaded and extracted zip into {saved['input_path']}",
612+
message=message,
515613
status_class="ok",
516614
output=json.dumps(saved, indent=2, ensure_ascii=False),
517615
)
518616
)
519617
except Exception as exc:
520-
self._send_html(render_page(message=f"Upload failed: {exc}", status_class="error"))
618+
message = f"Upload failed: {exc}"
619+
record_action_history("upload", False, "", None, None, None, message, [])
620+
self._send_html(render_page(message=message, status_class="error"))
521621

522622
def _send_download(self, token: str) -> None:
523623
path = DOWNLOAD_REGISTRY.get(token)
@@ -556,6 +656,7 @@ def render_page(
556656
status_class: str = "",
557657
output: str = '{\n "status": "idle"\n}',
558658
downloads: list[dict[str, str]] | None = None,
659+
history: list[dict[str, Any]] | None = None,
559660
) -> str:
560661
adapters = sorted(build_registry())
561662
profiles = sorted(list_profiles())
@@ -574,6 +675,7 @@ def render_page(
574675
status_class=_html_escape(status_class),
575676
output=_html_escape(output),
576677
download_links=render_download_links(downloads),
678+
history_panel=render_history_panel(history, _recent_downloads()),
577679
)
578680

579681

tests/test_roundtrip.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from memory_migrate_plugin.schema import build_canonical_package_schema, write_canonical_package_schema
2222
from memory_migrate_plugin.validate import validate_package, validate_package_file
2323
from memory_migrate_plugin.init_adapter import init_adapter
24-
from memory_migrate_plugin.serve import execute_web_action, register_download, render_page, save_uploaded_zip
24+
from memory_migrate_plugin.serve import ACTION_HISTORY, DOWNLOAD_REGISTRY, execute_web_action, record_action_history, register_download, render_history_panel, render_page, save_uploaded_zip
2525

2626

2727
class MemoryMigrateTests(unittest.TestCase):
@@ -365,6 +365,26 @@ def test_save_uploaded_zip_extracts_fixture_archive(self) -> None:
365365
self.assertTrue(Path(saved["zip_path"]).exists())
366366
self.assertTrue(Path(saved["input_path"]).exists())
367367

368+
def test_record_action_history_keeps_recent_entries_only(self) -> None:
369+
ACTION_HISTORY.clear()
370+
for index in range(15):
371+
record_action_history(f"action-{index}", True, "input", None, None, None, "ok", [])
372+
self.assertEqual(len(ACTION_HISTORY), 12)
373+
self.assertEqual(ACTION_HISTORY[0]["action"], "action-3")
374+
375+
def test_render_history_panel_shows_activity_and_downloads(self) -> None:
376+
ACTION_HISTORY.clear()
377+
DOWNLOAD_REGISTRY.clear()
378+
with tempfile.TemporaryDirectory() as tmpdir:
379+
bundle = Path(tmpdir) / "bundle.zip"
380+
bundle.write_bytes(b"demo")
381+
download = register_download(bundle)
382+
record_action_history("bundle", True, "fixtures/generic-json/sample.json", "generic-json", "codex-memories", str(bundle), "done", [download])
383+
html = render_history_panel()
384+
self.assertIn("Recent Activity", html)
385+
self.assertIn("Recent Downloads", html)
386+
self.assertIn("bundle.zip", html)
387+
368388
def test_register_download_returns_local_download_url(self) -> None:
369389
with tempfile.TemporaryDirectory() as tmpdir:
370390
target = Path(tmpdir) / "bundle.zip"
@@ -391,6 +411,7 @@ def test_render_page_contains_ui_shell(self) -> None:
391411
html = render_page()
392412
self.assertIn("Agent Memory Bridge", html)
393413
self.assertIn("Run Workflow", html)
414+
self.assertIn("Recent Activity", html)
394415

395416
def test_execute_web_action_detect_returns_matches(self) -> None:
396417
result = execute_web_action("detect", "fixtures/generic-json/sample.json")

0 commit comments

Comments
 (0)