trusty-memory 0.1.58

MCP server (stdio + HTTP/SSE) for trusty-memory
Documentation
<script>
  /*
   * Why: Both trusty-search and trusty-memory expose a `/logs/tail` ring
   * buffer; a single reusable viewer avoids duplicating the polling, level
   * filter, search, and auto-scroll logic in two places.
   * What: Polls a caller-supplied `fetchLogs()` every 2s, parses the
   * `[LEVEL]` prefix for client-side level filtering, supports a free-text
   * filter and an auto-scroll toggle. Monospace, newest-at-bottom.
   * Test: pass a stub `fetchLogs` returning lines with mixed levels, toggle
   * the ERROR filter, confirm only ERROR lines render.
   */
  import { onMount, onDestroy } from 'svelte';

  /** @type {{ fetchLogs: () => Promise<{lines: string[], total: number}> }} */
  let { fetchLogs } = $props();

  let lines = $state([]);
  let total = $state(0);
  let error = $state(null);
  let levelFilter = $state('ALL');
  let textFilter = $state('');
  let autoScroll = $state(true);
  let timer = null;
  let scrollEl;

  const LEVELS = ['ALL', 'INFO', 'WARN', 'ERROR'];

  /**
   * Why: tracing lines embed the level as `LEVEL` or `[LEVEL]`; we need it for
   * client-side filtering and row colouring.
   * What: returns the uppercased level token found in the line, or 'INFO'.
   * Test: lineLevel("2024 WARN foo") === "WARN".
   */
  function lineLevel(line) {
    const m = line.match(/\b(ERROR|WARN|INFO|DEBUG|TRACE)\b/);
    return m ? m[1] : 'INFO';
  }

  async function refresh() {
    try {
      const body = await fetchLogs();
      lines = body?.lines || [];
      total = body?.total ?? lines.length;
      error = null;
    } catch (e) {
      error = e.message || String(e);
    }
  }

  let visibleLines = $derived.by(() => {
    const needle = textFilter.trim().toLowerCase();
    return lines.filter((l) => {
      if (levelFilter !== 'ALL' && lineLevel(l) !== levelFilter) return false;
      if (needle && !l.toLowerCase().includes(needle)) return false;
      return true;
    });
  });

  // Auto-scroll to bottom whenever the visible set changes (if enabled).
  $effect(() => {
    void visibleLines;
    if (autoScroll && scrollEl) {
      queueMicrotask(() => {
        if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
      });
    }
  });

  onMount(() => {
    refresh();
    timer = setInterval(refresh, 2000);
  });
  onDestroy(() => {
    if (timer) clearInterval(timer);
  });
</script>

<div class="card">
  <div class="card-header log-toolbar">
    <div class="level-buttons">
      {#each LEVELS as lvl}
        <button
          class="btn btn-sm"
          class:btn-primary={levelFilter === lvl}
          onclick={() => (levelFilter = lvl)}
        >
          {lvl}
        </button>
      {/each}
    </div>
    <input
      type="text"
      class="input log-search"
      placeholder="Filter lines…"
      bind:value={textFilter}
    />
    <label class="autoscroll">
      <input type="checkbox" bind:checked={autoScroll} />
      <span>Auto-scroll</span>
    </label>
  </div>
  <div class="card-body" style="padding: 0">
    {#if error}
      <div class="log-error">{error}</div>
    {/if}
    <div class="log-pane" bind:this={scrollEl}>
      {#if visibleLines.length === 0}
        <div class="empty">
          {lines.length === 0 ? 'No log lines buffered.' : 'No lines match the current filter.'}
        </div>
      {:else}
        {#each visibleLines as line, i (i)}
          <div class="log-line lvl-{lineLevel(line).toLowerCase()}">{line}</div>
        {/each}
      {/if}
    </div>
    <div class="log-foot">
      <span class="text-xs text-muted">
        showing {visibleLines.length} of {lines.length} buffered ({total} total)
      </span>
    </div>
  </div>
</div>

<style>
  .log-toolbar {
    display: flex;
    align-items: center;
    gap: var(--trusty-space-3);
    flex-wrap: wrap;
  }
  .level-buttons {
    display: flex;
    gap: var(--trusty-space-1);
  }
  .log-search {
    flex: 1;
    min-width: 160px;
  }
  .autoscroll {
    display: flex;
    align-items: center;
    gap: var(--trusty-space-1);
    font-size: var(--trusty-fs-sm);
    color: var(--trusty-text-secondary);
    cursor: pointer;
    white-space: nowrap;
  }
  .log-pane {
    max-height: 60vh;
    overflow: auto;
    background: #1e1e2e;
    padding: var(--trusty-space-3);
  }
  .log-line {
    font-family: var(--trusty-mono);
    font-size: var(--trusty-fs-xs);
    line-height: 1.6;
    color: #cdd6f4;
    white-space: pre-wrap;
    word-break: break-word;
  }
  .log-line.lvl-error {
    color: #f38ba8;
  }
  .log-line.lvl-warn {
    color: #f9e2af;
  }
  .log-line.lvl-debug,
  .log-line.lvl-trace {
    color: #7f849c;
  }
  .log-error {
    padding: var(--trusty-space-3) var(--trusty-space-5);
    color: var(--trusty-danger);
    background: var(--trusty-danger-soft);
    font-size: var(--trusty-fs-sm);
  }
  .log-foot {
    padding: var(--trusty-space-2) var(--trusty-space-4);
    border-top: 1px solid var(--trusty-border);
  }
  @media (max-width: 480px) {
    .log-search {
      order: 3;
      width: 100%;
      flex-basis: 100%;
    }
  }
</style>