Files
Lumotia/scripts/parse-activation-log.py
Jake 3770815fbf
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
agent: lumotia — v0.1 release-completion run
Closes the code-side v0.1 ship gate. All quality gates green:
cargo fmt/clippy/test (~327 tests), npm check (0/0), vitest 13/13,
scripts/dogfood-rebrand-drill.sh 8/8.

Phase F — first-run onboarding promoted to v0.1
- FirstRunPage with skip-to-main + failure recovery + event recording
- Six onboarding commands (record/list/has-completed + lumotia_events)
- Storage migration v17 (onboarding_events + lumotia_events tables)

UI hardening (in-scope items from v0.1-ui-hardening.md)
- StatusPill + PostCaptureCard components, 21st preview entry
- Sidebar recording-as-sacred-state (opacity + aria-disabled, reduced-motion)
- Settings 6-section regroup + Help section + Activation log + Privacy toggle
- Error-state copy sweep (DictationPage + SettingsPage, plain-language)
- Global :focus-visible rule, textarea outlines restored
- Ctrl+K / Ctrl+, / Escape bindings in +layout

LLM resilience
- rule_based_extract_tasks (regex-free imperative-verb extractor) +
  extract_tasks_with_fallback wrapper — task extraction never returns zero
- tokio::time::timeout(120s) wraps cleanup/tags/tasks commands

Release artefacts
- LICENSE (canonical AGPL-3.0), CHANGELOG (Keep-a-Changelog format)
- v0.1-release-notes, privacy-and-ai-use, install-warnings,
  tester-onboarding-kit, tester-acceptance-runbook, code-signing-setup,
  apple-silicon-rb08-runbook, virtual-audio-setup, v0.1-contrast-audit
- Workspace versioning + AGPL spdx; npm exact-pin (10 ranges removed)
- AppImage SHA-256 sidecar in build.yml
- README v0.1 section + Reporting-issues; canonical repo slug

Closure pass — items moved from human-required to code-complete
- KI-02 Linux idle inhibit: zbus 5 → org.freedesktop.login1.Manager.Inhibit
- KI-03 Windows sleep prevention: SetThreadExecutionState(ES_CONTINUOUS|...)
- acquire/release_idle_inhibit Tauri commands, wired in DictationPage
- Diagnostic-bundle frontend wire-up (Settings → Help button)
- WCAG-AA contrast fix via .btn-filled-text utility (no token changes)
- 8 destructive-action sites wrapped in plain-language confirm() guards
- KNOWN-ISSUES.md + v0.1-known-limitations.md updated (KI-02/03 fixed)

Scripts
- pre-tag-verify.sh, tag-day.sh, smoke-linux + driver
- parse-diagnostic-bundle.sh, parse-activation-log.py

Per-item audit trail: docs/release/v0.1-completion-status.md
Remaining: W-01…W-08 (signing certs, hardware probes, smoke matrix,
tester recruitment) — see docs/release/v0.1-known-limitations.md.
2026-05-15 06:59:08 +01:00

158 lines
6.6 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Summarise a Lumotia activation log for tester review. Stdlib only.
Usage:
python3 scripts/parse-activation-log.py path/to/tester-activation.json
python3 scripts/parse-activation-log.py --paste # reads stdin (tab/pipe table or JSON)
"""
import json, re, sys
from datetime import datetime, timezone
from pathlib import Path
# Activation metric thresholds (v0.1 tester runbook)
WARMUP_MINUTES = 3 # first capture < N min from open = activated
CORE_VALUE_COUNT = 3 # captures in first 24h
DAY = 86400
# Event kinds that count as "capture completed"
CAPTURE_KINDS = {
'first_capture', 'recording_completed', 'recording_saved',
'capture_completed', 'transcript_saved',
}
# Deny patterns — scrub payload strings before printing anything
_DENY = re.compile(
r'transcripts?/|captures?/|audio/|\.wav\b|\.mp3\b|\.opus\b|\.ogg\b|\.flac\b|\.db\b',
re.IGNORECASE,
)
def _utc(ts): return datetime.fromtimestamp(ts, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
def _delta(a, b):
d = abs(b - a)
if d < 60: return f"{d}s"
if d < 3600: return f"{d//60} min"
if d < DAY: return f"{d//3600}h {(d%3600)//60}m"
return f"{d//DAY}d {(d%DAY)//3600}h"
def _parse_json(text):
data = json.loads(text)
if not isinstance(data, list): raise ValueError("Expected JSON array")
return data
def _parse_table(text):
"""Best-effort: extract rows from a pipe/tab-delimited table or HTML paste."""
try: return _parse_json(text)
except (json.JSONDecodeError, ValueError): pass
html = re.compile(r'<[^>]+>')
events = []
for line in text.splitlines():
line = html.sub('', line).strip()
cells = [c.strip() for c in re.split(r'\||\t', line) if c.strip()]
if len(cells) < 3 or cells[0].lower() in ('id', '#'): continue
try:
raw_ts = cells[2]
if re.match(r'^\d+$', raw_ts):
ts = int(raw_ts)
else:
ts = int(datetime.fromisoformat(raw_ts.replace('Z','+00:00'))
.astimezone(timezone.utc).timestamp())
events.append({'id': int(cells[0]), 'kind': cells[1], 'occurred_at': ts,
'payload': cells[3] if len(cells) > 3 else None})
except (ValueError, IndexError):
continue
return events
def _metrics(events):
if not events: return {}
ev = sorted(events, key=lambda e: e.get('occurred_at', 0))
by_kind = {}
for e in ev: by_kind.setdefault(e.get('kind',''), []).append(e)
first_ts, last_ts = ev[0]['occurred_at'], ev[-1]['occurred_at']
def _first(*kinds):
for e in ev:
if e.get('kind') in set().union(*kinds): return e['occurred_at']
return None
fc = _first({'first_capture'}, CAPTURE_KINDS)
fe = _first({'first_export','transcript_exported','export_completed'})
ft = _first({'first_task_extract','task_extracted','tasks_extracted'})
cap_24h = sum(1 for e in ev if e['kind'] in CAPTURE_KINDS and e['occurred_at']-first_ts <= DAY)
cap_7d = sum(1 for e in ev if e['kind'] in CAPTURE_KINDS and e['occurred_at']-first_ts <= 7*DAY)
returned = (last_ts - first_ts) <= 7*DAY if last_ts != first_ts else None
return dict(ev=ev, by_kind=by_kind, first_ts=first_ts, last_ts=last_ts,
fc=fc, fe=fe, ft=ft, cap_24h=cap_24h, cap_7d=cap_7d, returned=returned)
def _render(m, label):
if not m: return "ERROR: no events found — nothing to summarise.\n"
L = []
hdr = f"LUMOTIA ACTIVATION LOG SUMMARY ({label})"
L += [hdr, '='*len(hdr), '']
L.append(f"Total events: {len(m['ev'])}")
L.append(f"First event: {_utc(m['first_ts'])}")
L.append(f"Last event: {_utc(m['last_ts'])} (span: {_delta(m['first_ts'], m['last_ts'])})")
L.append('')
def _ms(label, ts, ref=None):
if ts is None: return f"{label:<22} (not recorded)"
s = f"{label:<22} {_utc(ts)}"
if ref and ref != ts: s += f" ({_delta(ref, ts)} after first capture)"
return s
L += [_ms("First capture:", m['fc']),
_ms("First export:", m['fe'], m['fc']),
_ms("First task extract:", m['ft'], m['fc']), '']
L.append(f"Captures (24h): {m['cap_24h']} (target: >= {CORE_VALUE_COUNT})")
L.append(f"Captures (7-day): {m['cap_7d']}")
ret = m['returned']
if ret is True: ret_s = f"yes (last event {_utc(m['last_ts'])})"
elif ret is False: ret_s = f"no (last seen {_utc(m['last_ts'])}{_delta(m['first_ts'],m['last_ts'])} after first)"
else: ret_s = "n/a (only one event recorded)"
L += [f"Returned within 7d: {ret_s}", '', "ACTIVATION METRICS:"]
def ok(flag, label, detail):
t = "" if flag else " ×"
return f"{t} {label}: {detail}"
def q(label, detail): return f" ? {label}: {detail}"
L.append(ok(m['fc'] is not None, "Activation",
"first_capture event present" if m['fc'] else "no capture event recorded"))
L.append(ok(m['cap_24h'] >= CORE_VALUE_COUNT, "Core value",
f"{m['cap_24h']} captures in first 24h (target: >= {CORE_VALUE_COUNT})"))
if ret is True: L.append(ok(True, "Retention", "returned within 7 days"))
elif ret is False: L.append(ok(False, "Retention", "no return event within 7 days"))
else: L.append(q("Retention", "only one session — check day-3"))
L.append(q("Quality", "extracted tasks accepted/edited (not in activation log — ask tester)"))
L.append(q("Trust", "can articulate 'what stays local' (not in activation log — ask tester)"))
L += ['', "EVENT BREAKDOWN:"]
for kind, evs in sorted(m['by_kind'].items()):
L.append(f" {kind:<35} x{len(evs)}")
L += ['', "NOTE: ? items require qualitative follow-up per the tester-onboarding-kit."]
return '\n'.join(L) + '\n'
def main():
args = sys.argv[1:]
if not args: print(__doc__); sys.exit(1)
if args[0] == '--paste':
raw, label = sys.stdin.read(), 'stdin (paste)'
parse = _parse_table
else:
p = Path(args[0])
if not p.exists(): print(f"ERROR: file not found: {p}", file=sys.stderr); sys.exit(2)
raw, label = p.read_text(encoding='utf-8', errors='replace'), p.name
parse = _parse_table # tries JSON first, then table
try:
events = parse(raw)
except Exception as exc:
print(f"ERROR: could not parse input — {exc}", file=sys.stderr); sys.exit(2)
for ev in events:
if isinstance(ev.get('payload'), str):
ev['payload'] = _DENY.sub('[redacted]', ev['payload'])
print(_render(_metrics(events), label))
if __name__ == '__main__':
main()