sync: update from private repo (a67c053)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
cbdfdeaa18
commit
138401ce1b
56
.env.example
56
.env.example
@ -1,6 +1,58 @@
|
||||
# Docker Compose reaches an LLM running on the host through this address.
|
||||
# ---------------------------------------------------------------------------
|
||||
# MAESTRO environment overrides
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
# Loaded by:
|
||||
# - Docker Compose (docker-compose.yml: env_file: .env)
|
||||
# - scripts/server.sh (bare-metal: each KEY=value is exported before launch)
|
||||
#
|
||||
# Precedence: explicit shell env > .env > config.yaml > built-in defaults.
|
||||
# Every key is OPTIONAL. The uncommented defaults below target the Docker path;
|
||||
# bare-metal installs usually generate config.yaml with `npm run setup` and only
|
||||
# need this file for the LLM endpoint and HOST.
|
||||
#
|
||||
# NOTE: under Docker Compose, PORT / DB_PATH / WORKTREE_DIR are also pinned in
|
||||
# docker-compose.yml's `environment:` block, which overrides this file. Editing
|
||||
# them here only takes effect on a bare-metal (scripts/server.sh) launch.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# --- LLM connection --------------------------------------------------------
|
||||
# OpenAI-compatible endpoint. From inside a Docker container, host.docker.internal
|
||||
# reaches an LLM (Ollama / vLLM, ...) running on the host.
|
||||
OLLAMA_BASE_URL=http://host.docker.internal:11434/v1
|
||||
OLLAMA_MODEL=qwen3:32b
|
||||
|
||||
# Required only when enabling MCP or SSH.
|
||||
# --- Network bind ----------------------------------------------------------
|
||||
# Address the HTTP(S) server binds to. Default: 127.0.0.1 (loopback only).
|
||||
# The agent API includes a Bash tool, so an auth-less instance reachable on the
|
||||
# LAN is effectively unauthenticated remote code execution.
|
||||
#
|
||||
# To reach MAESTRO from another machine on a BARE-METAL install, set HOST
|
||||
# explicitly (e.g. 0.0.0.0) -- and enable auth in config.yaml first.
|
||||
# (The Docker image already sets HOST=0.0.0.0 inside the container; external
|
||||
# access is gated by the 127.0.0.1:9876 port mapping in docker-compose.yml.)
|
||||
# HOST=0.0.0.0
|
||||
|
||||
# HTTP(S) listen port (default 9876).
|
||||
# PORT=9876
|
||||
|
||||
# --- Storage paths ---------------------------------------------------------
|
||||
# SQLite database file (default ./data/maestro.db).
|
||||
# DB_PATH=./data/maestro.db
|
||||
|
||||
# Agent workspace base directory (default ./data/workspaces; mirrors
|
||||
# storage.worktree_dir in config.yaml).
|
||||
# WORKTREE_DIR=./data/workspaces
|
||||
|
||||
# --- Runtime ---------------------------------------------------------------
|
||||
# Max concurrent jobs across all workers (overrides `concurrency` in config.yaml).
|
||||
# CONCURRENCY=4
|
||||
|
||||
# Log verbosity: debug | info | warn | error (default info).
|
||||
# LOG_LEVEL=info
|
||||
|
||||
# --- Secrets ---------------------------------------------------------------
|
||||
# Required ONLY when using MCP servers or the SSH tool. 64 hex characters.
|
||||
# Generate one with: openssl rand -hex 32
|
||||
# MCP_ENCRYPTION_KEY=replace-with-64-hex-characters
|
||||
|
||||
@ -73,6 +73,27 @@ scripts/server.sh stop
|
||||
> `--skip-python`。システム Python への書き込みに権限が要る環境では
|
||||
> `sudo bash scripts/prebake-python.sh` を別途実行する。
|
||||
|
||||
### 既存環境の更新(`git pull` の後)
|
||||
|
||||
bare-metal の既存環境を更新するときは次を実行する。
|
||||
|
||||
```bash
|
||||
scripts/upgrade.sh # git pull → 再ビルド(依存 + サーバー + UI)→ 再起動
|
||||
```
|
||||
|
||||
`scripts/server.sh restart` はサーバーしか再ビルドせず、**UI は再ビルドしない**
|
||||
(`ui/dist` は gitignore 対象で別途ビルドされる)。npm 依存も更新しない。そのため
|
||||
依存やフロントエンドが変わった pull の後にそのまま restart すると、UI バンドルが
|
||||
古いまま残ることがある。`scripts/upgrade.sh` は正しい手順を一括で実行する。
|
||||
|
||||
**ネットワークバインドの移行**も扱う。2026-06-10 以降、サーバーのデフォルトバインドが
|
||||
セキュリティのため `0.0.0.0` から `127.0.0.1`(loopback 限定)に変わった(エージェント
|
||||
API には Bash ツールが含まれるため、認証なしで LAN に晒すと実質的に認証なし RCE になる)。
|
||||
別マシンから MAESTRO にアクセスしていて、更新後に突然 `ERR_CONNECTION_REFUSED` が出る
|
||||
場合はこれが原因。`HOST` を明示的に設定し(例: `.env` に `HOST=0.0.0.0`)、先に
|
||||
`config.yaml` で認証を有効にすること。upgrade スクリプトはこの状況を検出して設定を
|
||||
提案する。
|
||||
|
||||
## 5. Docker で起動
|
||||
|
||||
```bash
|
||||
|
||||
@ -73,6 +73,27 @@ scripts/server.sh stop
|
||||
> `--skip-python`. In environments where writing to the system Python requires permissions, run
|
||||
> `sudo bash scripts/prebake-python.sh` separately.
|
||||
|
||||
### Updating an existing install (after `git pull`)
|
||||
|
||||
To update a bare-metal install, run:
|
||||
|
||||
```bash
|
||||
scripts/upgrade.sh # git pull -> rebuild (deps + server + UI) -> restart
|
||||
```
|
||||
|
||||
A plain `scripts/server.sh restart` rebuilds only the server, **not** the UI
|
||||
(`ui/dist` is gitignored and built separately) and never refreshes npm deps, so
|
||||
after a pull that changed dependencies or the frontend it can leave a stale UI
|
||||
bundle. `scripts/upgrade.sh` runs the full, correct sequence instead.
|
||||
|
||||
It also handles the **network bind migration**: since 2026-06-10 the server
|
||||
binds `127.0.0.1` (loopback only) by default instead of `0.0.0.0`, for security
|
||||
(the agent API includes a Bash tool, so an auth-less instance on the LAN is
|
||||
effectively unauthenticated RCE). If you reach MAESTRO from another machine and
|
||||
suddenly get `ERR_CONNECTION_REFUSED` after updating, that is why — set `HOST`
|
||||
explicitly (e.g. `HOST=0.0.0.0` in `.env`) and enable auth in `config.yaml`
|
||||
first. The upgrade script detects this and offers to set it for you.
|
||||
|
||||
## 5. Launch with Docker
|
||||
|
||||
```bash
|
||||
|
||||
142
scripts/upgrade.sh
Executable file
142
scripts/upgrade.sh
Executable file
@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# upgrade.sh — bring an existing (bare-metal) MAESTRO checkout safely up to the
|
||||
# latest version after a `git pull`.
|
||||
#
|
||||
# Why this exists
|
||||
# ---------------
|
||||
# `scripts/server.sh restart` rebuilds the server (dist/) but NOT the UI:
|
||||
# `ui/dist` is gitignored and built separately, and npm deps are never
|
||||
# refreshed. So after a `git pull` that touched dependencies or the frontend, a
|
||||
# plain restart can leave a stale/missing UI bundle or ABI-mismatched native
|
||||
# modules. This script runs the full, correct update sequence instead:
|
||||
#
|
||||
# git pull (optional) -> build-all.sh (deps + server + UI) -> restart
|
||||
#
|
||||
# Network bind migration (2026-06-10)
|
||||
# -----------------------------------
|
||||
# A security fix changed the default bind from 0.0.0.0 to 127.0.0.1 (loopback
|
||||
# only). If you used to reach MAESTRO from another machine on a bare-metal
|
||||
# install, after upgrading you'll get ERR_CONNECTION_REFUSED until you set HOST
|
||||
# explicitly. This script detects that and offers to write HOST to .env (with a
|
||||
# warning that you must enable auth before exposing a non-loopback interface).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/upgrade.sh # interactive: pull, build, restart
|
||||
# scripts/upgrade.sh --no-pull # skip `git pull` (build + restart only)
|
||||
# scripts/upgrade.sh --no-restart # build only, leave the server as-is
|
||||
# scripts/upgrade.sh --yes # non-interactive (CI); never edits .env
|
||||
# scripts/upgrade.sh --help
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ENV_FILE="$PROJECT_DIR/.env"
|
||||
|
||||
DO_PULL=1
|
||||
DO_RESTART=1
|
||||
ASSUME_YES=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-pull) DO_PULL=0 ;;
|
||||
--no-restart) DO_RESTART=0 ;;
|
||||
--yes|-y) ASSUME_YES=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "upgrade.sh: unknown option '$arg' (try --help)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
say() { printf '\n\033[1;36m==>\033[0m %s\n' "$1"; }
|
||||
warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$1" >&2; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. git pull (fast-forward only; never silently merge or rewrite local work)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "$DO_PULL" -eq 1 ]]; then
|
||||
if [[ -n "$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null)" ]]; then
|
||||
warn "working tree has local changes — skipping 'git pull'. Commit/stash and re-run, or use --no-pull."
|
||||
else
|
||||
say "Pulling latest changes (git pull --ff-only)"
|
||||
if ! git -C "$PROJECT_DIR" pull --ff-only; then
|
||||
warn "git pull --ff-only failed (diverged or offline). Continuing with the current checkout."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
say "Skipping git pull (--no-pull)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Full rebuild: refresh deps + build server dist/ AND ui/dist/ + prebake.
|
||||
# This is the step a plain `server.sh restart` skips for the UI.
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Rebuilding (deps + server + UI) via scripts/build-all.sh"
|
||||
"$SCRIPT_DIR/build-all.sh"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Network bind migration check (0.0.0.0 -> 127.0.0.1 default since 2026-06-10)
|
||||
# ---------------------------------------------------------------------------
|
||||
host_in_env=""
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
# First non-comment HOST=... line, value with surrounding quotes stripped.
|
||||
host_in_env="$(grep -E '^[[:space:]]*(export[[:space:]]+)?HOST=' "$ENV_FILE" \
|
||||
| grep -vE '^[[:space:]]*#' \
|
||||
| head -1 | sed -E 's/^[[:space:]]*(export[[:space:]]+)?HOST=//; s/^["'\'']//; s/["'\'']$//')"
|
||||
fi
|
||||
|
||||
say "Checking network bind"
|
||||
if [[ -n "${HOST:-}" ]]; then
|
||||
echo " HOST is set in the environment ($HOST) — bind is explicit, nothing to migrate."
|
||||
elif [[ -n "$host_in_env" ]]; then
|
||||
echo " HOST=$host_in_env found in .env — bind is explicit, nothing to migrate."
|
||||
else
|
||||
cat <<'EOF'
|
||||
HOST is not set, so the server now binds 127.0.0.1 (loopback only).
|
||||
Since 2026-06-10 the default changed from 0.0.0.0 for security: the agent
|
||||
API includes a Bash tool, so an auth-less instance on the LAN is effectively
|
||||
unauthenticated remote code execution.
|
||||
|
||||
If you ONLY open MAESTRO from this same machine (http://localhost:9876),
|
||||
no action is needed.
|
||||
|
||||
If you reach it from ANOTHER machine, that is why you now get
|
||||
ERR_CONNECTION_REFUSED. To restore remote access you must set HOST
|
||||
explicitly -- and you should enable auth (auth.local or OAuth) in
|
||||
config.yaml before exposing a non-loopback interface.
|
||||
EOF
|
||||
if [[ "$ASSUME_YES" -eq 0 ]]; then
|
||||
printf '\n Append HOST=0.0.0.0 to .env now? Only do this if auth is (or will be) enabled. [y/N] '
|
||||
read -r reply || reply=""
|
||||
if [[ "$reply" =~ ^[Yy]$ ]]; then
|
||||
{ [[ -f "$ENV_FILE" && -n "$(tail -c1 "$ENV_FILE" 2>/dev/null)" ]] && echo "" >> "$ENV_FILE"; } || true
|
||||
printf '\n# Added by scripts/upgrade.sh: restore non-loopback bind (enable auth first!)\nHOST=0.0.0.0\n' >> "$ENV_FILE"
|
||||
echo " Wrote HOST=0.0.0.0 to $ENV_FILE"
|
||||
else
|
||||
echo " Left .env unchanged. Set HOST=0.0.0.0 in .env manually when ready."
|
||||
fi
|
||||
else
|
||||
warn "non-interactive (--yes): not editing .env. Set HOST in .env manually if you need remote access."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Restart
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "$DO_RESTART" -eq 1 ]]; then
|
||||
say "Restarting server (scripts/server.sh restart)"
|
||||
"$SCRIPT_DIR/server.sh" restart
|
||||
echo
|
||||
echo "Done. If the server is healthy, open it at the bind address reported above"
|
||||
echo "(loopback default: http://localhost:9876)."
|
||||
else
|
||||
say "Build complete (--no-restart). Start the server with: scripts/server.sh restart"
|
||||
fi
|
||||
@ -6,15 +6,29 @@
|
||||
* なので User Folder へ移設(Settings 側は Reflection タイムラインのみ)。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { HelpText } from '../settings/HelpText';
|
||||
import { summarizeMemory, filterMemory, MEMORY_TYPES, type MemoryType } from '../../lib/memorySummary';
|
||||
|
||||
// ── Per-type accent classes (literal strings so Tailwind keeps them) ───────────
|
||||
|
||||
const TYPE_BADGE: Record<MemoryType, string> = {
|
||||
user: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30',
|
||||
feedback: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30',
|
||||
project: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30',
|
||||
reference: 'bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30',
|
||||
};
|
||||
const TYPE_DOT: Record<MemoryType, string> = {
|
||||
user: 'bg-blue-500',
|
||||
feedback: 'bg-amber-500',
|
||||
project: 'bg-emerald-500',
|
||||
reference: 'bg-violet-500',
|
||||
};
|
||||
|
||||
// ── API types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type MemoryType = 'user' | 'feedback' | 'project' | 'reference';
|
||||
|
||||
// Mirrors the server's flat shape from `listMemoryEntries` in
|
||||
// src/user-folder/memory.ts and `GET /api/local/memory/entries` in
|
||||
// src/bridge/memory-api.ts. If you change this shape, update both.
|
||||
@ -88,8 +102,6 @@ interface EntryFormState {
|
||||
body: string;
|
||||
}
|
||||
|
||||
const MEMORY_TYPES: MemoryType[] = ['user', 'feedback', 'project', 'reference'];
|
||||
|
||||
function MemoryEntryModal({
|
||||
initial,
|
||||
isNew,
|
||||
@ -129,7 +141,7 @@ function MemoryEntryModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh]">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-full max-w-4xl mx-4 flex flex-col h-[88vh] max-h-[88vh]">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-hairline">
|
||||
<h3 className="text-sm font-semibold text-slate-800">
|
||||
{isNew ? t('memory.modal.newTitle') : t('memory.modal.editTitle', { name: initial.name })}
|
||||
@ -143,7 +155,7 @@ function MemoryEntryModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 space-y-3 flex-1">
|
||||
<div className="overflow-y-auto p-4 space-y-3 flex-1 flex flex-col min-h-0">
|
||||
{/* Name — only editable when creating */}
|
||||
<div>
|
||||
<label className="block text-2xs font-medium text-slate-600 mb-1">
|
||||
@ -189,13 +201,12 @@ function MemoryEntryModal({
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<label className="block text-2xs font-medium text-slate-600 mb-1">{t('memory.modal.body')}</label>
|
||||
<textarea
|
||||
value={form.body}
|
||||
onChange={e => set('body', e.target.value)}
|
||||
rows={8}
|
||||
className="w-full px-2.5 py-2 text-xs font-mono border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none resize-y"
|
||||
className="w-full flex-1 min-h-[240px] px-2.5 py-2 text-xs font-mono border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none resize-y"
|
||||
placeholder={t('memory.modal.bodyPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
@ -249,6 +260,24 @@ function MemoryEntriesPanel() {
|
||||
const [modal, setModal] = useState<{ entry: EntryFormState; isNew: boolean } | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<MemoryType | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const allEntries = data?.entries ?? [];
|
||||
const summary = useMemo(() => summarizeMemory(allEntries), [allEntries]);
|
||||
const filtered = useMemo(
|
||||
() => filterMemory(allEntries, { query, type: typeFilter }),
|
||||
[allEntries, query, typeFilter],
|
||||
);
|
||||
|
||||
const toggleExpand = (name: string) =>
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
|
||||
const handleNew = () => {
|
||||
setModal({
|
||||
@ -330,43 +359,115 @@ function MemoryEntriesPanel() {
|
||||
)}
|
||||
|
||||
{data && data.entries.length > 0 && (
|
||||
<ul className="divide-y divide-hairline">
|
||||
{data.entries.map(entry => (
|
||||
<li key={entry.name} className="flex items-start gap-3 px-4 py-3 hover:bg-surface/60 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono font-medium text-slate-800 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-500 flex-shrink-0">
|
||||
{entry.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-500 mt-0.5 truncate">{entry.description}</p>
|
||||
{entry.body && (
|
||||
<p className="text-2xs text-slate-400 mt-0.5 line-clamp-2 font-mono whitespace-pre-wrap break-words">
|
||||
{entry.body.slice(0, 200)}{entry.body.length > 200 ? '…' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-shrink-0 mt-0.5">
|
||||
<button
|
||||
onClick={() => handleEdit(entry)}
|
||||
className="px-2 h-6 text-2xs text-slate-600 border border-hairline bg-canvas hover:bg-surface rounded transition-colors"
|
||||
>
|
||||
{t('memory.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDelete(entry.name)}
|
||||
disabled={deleting === entry.name}
|
||||
className="px-2 h-6 text-2xs text-red-700 dark:text-red-300 border border-red-200 bg-canvas hover:bg-red-50 dark:hover:bg-red-500/15 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deleting === entry.name ? '…' : t('memory.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
{/* Summary + filters: total count and a clickable chip per type so you
|
||||
can see at a glance how much of each kind is stored, and filter. */}
|
||||
<div className="px-4 py-3 border-b border-hairline bg-surface/40 space-y-2.5">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setTypeFilter(null)}
|
||||
aria-pressed={typeFilter === null}
|
||||
className={`inline-flex items-center gap-1.5 px-2 h-7 rounded-md text-2xs font-medium border transition-colors ${
|
||||
typeFilter === null
|
||||
? 'border-accent bg-accent/10 text-slate-900 font-semibold'
|
||||
: 'border-hairline bg-canvas text-slate-600 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
{t('memory.filterAll')}
|
||||
<span className="tabular-nums text-slate-500">{summary.total}</span>
|
||||
</button>
|
||||
{MEMORY_TYPES.map(type => {
|
||||
const active = typeFilter === type;
|
||||
const count = summary.byType[type];
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setTypeFilter(active ? null : type)}
|
||||
aria-pressed={active}
|
||||
className={`inline-flex items-center gap-1.5 px-2 h-7 rounded-md text-2xs font-medium border transition-colors ${
|
||||
active ? TYPE_BADGE[type] + ' font-semibold' : 'border-hairline bg-canvas text-slate-600 hover:bg-surface'
|
||||
} ${count === 0 ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${TYPE_DOT[type]}`} aria-hidden="true" />
|
||||
{type}
|
||||
<span className="tabular-nums">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('memory.searchPlaceholder')}
|
||||
className="w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-2xs text-slate-400">{t('memory.noMatches')}</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-hairline">
|
||||
{filtered.map(entry => {
|
||||
const isExpanded = expanded.has(entry.name);
|
||||
return (
|
||||
<li key={entry.name} className="flex items-start gap-3 px-4 py-3 hover:bg-surface/60 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono font-medium text-slate-800 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded border flex-shrink-0 ${TYPE_BADGE[entry.type]}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${TYPE_DOT[entry.type]}`} aria-hidden="true" />
|
||||
{entry.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">{entry.description}</p>
|
||||
{entry.body && (() => {
|
||||
// Only offer expand when the body is long enough to be clamped.
|
||||
const longBody = entry.body.length > 120 || entry.body.split('\n').length > 2;
|
||||
return (
|
||||
<>
|
||||
<pre className={`text-2xs text-slate-500 mt-1 font-mono whitespace-pre-wrap break-words ${
|
||||
isExpanded || !longBody
|
||||
? (longBody ? 'max-h-72 overflow-y-auto bg-surface/60 border border-hairline rounded p-2' : '')
|
||||
: 'line-clamp-2'
|
||||
}`}>
|
||||
{entry.body}
|
||||
</pre>
|
||||
{longBody && (
|
||||
<button
|
||||
onClick={() => toggleExpand(entry.name)}
|
||||
className="mt-1 text-[10px] text-accent hover:underline"
|
||||
>
|
||||
{isExpanded ? t('memory.collapse') : t('memory.expand')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-shrink-0 mt-0.5">
|
||||
<button
|
||||
onClick={() => handleEdit(entry)}
|
||||
className="px-2 h-6 text-2xs text-slate-600 border border-hairline bg-canvas hover:bg-surface rounded transition-colors"
|
||||
>
|
||||
{t('memory.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDelete(entry.name)}
|
||||
disabled={deleting === entry.name}
|
||||
className="px-2 h-6 text-2xs text-red-700 dark:text-red-300 border border-red-200 bg-canvas hover:bg-red-50 dark:hover:bg-red-500/15 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deleting === entry.name ? '…' : t('memory.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
|
||||
@ -163,6 +163,11 @@
|
||||
"entriesTitle": "Memory entries",
|
||||
"entriesSubtitle": "Persistent information injected into every agent session.",
|
||||
"newEntry": "+ New entry",
|
||||
"filterAll": "All",
|
||||
"searchPlaceholder": "Search memory (name, description, body)",
|
||||
"noMatches": "No memory entries match your filter.",
|
||||
"expand": "Show full",
|
||||
"collapse": "Collapse",
|
||||
"loading": "Loading…",
|
||||
"loadFailed": "Failed to load memory entries: {{error}}",
|
||||
"fetchFailed": "Failed to load memory entries ({{status}})",
|
||||
|
||||
@ -163,6 +163,11 @@
|
||||
"entriesTitle": "メモリエントリ",
|
||||
"entriesSubtitle": "エージェントの毎セッションに注入される永続的な情報。",
|
||||
"newEntry": "+ 新しいエントリ",
|
||||
"filterAll": "すべて",
|
||||
"searchPlaceholder": "メモリを検索(名前・説明・本文)",
|
||||
"noMatches": "条件に一致するメモリはありません。",
|
||||
"expand": "全文を表示",
|
||||
"collapse": "折りたたむ",
|
||||
"loading": "読み込み中…",
|
||||
"loadFailed": "メモリエントリの読み込みに失敗しました: {{error}}",
|
||||
"fetchFailed": "メモリエントリの読み込みに失敗しました ({{status}})",
|
||||
|
||||
48
ui/src/lib/memorySummary.test.ts
Normal file
48
ui/src/lib/memorySummary.test.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { summarizeMemory, filterMemory, type MemoryEntryLike } from './memorySummary';
|
||||
|
||||
const E = (name: string, type: MemoryEntryLike['type'], description = '', body = ''): MemoryEntryLike =>
|
||||
({ name, type, description, body });
|
||||
|
||||
const entries: MemoryEntryLike[] = [
|
||||
E('a', 'user', 'who I am', 'role: admin'),
|
||||
E('b', 'feedback', 'no filler', 'avoid agreement'),
|
||||
E('c', 'feedback', 'investigate first', 'check config'),
|
||||
E('d', 'project', 'maestro rename', 'PR #385'),
|
||||
E('e', 'reference', 'gitea token', 'in ~/.gitea-token'),
|
||||
];
|
||||
|
||||
describe('summarizeMemory', () => {
|
||||
it('counts total and per type', () => {
|
||||
expect(summarizeMemory(entries)).toEqual({
|
||||
total: 5,
|
||||
byType: { user: 1, feedback: 2, project: 1, reference: 1 },
|
||||
});
|
||||
});
|
||||
it('returns zeroed counts for an empty list', () => {
|
||||
expect(summarizeMemory([])).toEqual({
|
||||
total: 0,
|
||||
byType: { user: 0, feedback: 0, project: 0, reference: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterMemory', () => {
|
||||
it('returns all when no query/type', () => {
|
||||
expect(filterMemory(entries, {})).toHaveLength(5);
|
||||
});
|
||||
it('filters by type', () => {
|
||||
expect(filterMemory(entries, { type: 'feedback' }).map(e => e.name)).toEqual(['b', 'c']);
|
||||
});
|
||||
it('searches name, description and body (case-insensitive)', () => {
|
||||
expect(filterMemory(entries, { query: 'ADMIN' }).map(e => e.name)).toEqual(['a']); // body
|
||||
expect(filterMemory(entries, { query: 'rename' }).map(e => e.name)).toEqual(['d']); // description
|
||||
expect(filterMemory(entries, { query: 'gitea' }).map(e => e.name)).toEqual(['e']); // name+body
|
||||
});
|
||||
it('combines type and query', () => {
|
||||
expect(filterMemory(entries, { type: 'feedback', query: 'config' }).map(e => e.name)).toEqual(['c']);
|
||||
});
|
||||
it('ignores a null type', () => {
|
||||
expect(filterMemory(entries, { type: null, query: '' })).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
45
ui/src/lib/memorySummary.ts
Normal file
45
ui/src/lib/memorySummary.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* memorySummary.ts — pure helpers for the memory panel: count entries per type
|
||||
* and filter by type / free-text. Kept separate from the component so it's
|
||||
* unit-testable.
|
||||
*/
|
||||
|
||||
export type MemoryType = 'user' | 'feedback' | 'project' | 'reference';
|
||||
|
||||
export const MEMORY_TYPES: MemoryType[] = ['user', 'feedback', 'project', 'reference'];
|
||||
|
||||
export interface MemoryEntryLike {
|
||||
name: string;
|
||||
description: string;
|
||||
type: MemoryType;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface MemorySummary {
|
||||
total: number;
|
||||
byType: Record<MemoryType, number>;
|
||||
}
|
||||
|
||||
export function summarizeMemory(entries: MemoryEntryLike[]): MemorySummary {
|
||||
const byType: Record<MemoryType, number> = { user: 0, feedback: 0, project: 0, reference: 0 };
|
||||
for (const e of entries) {
|
||||
if (e.type in byType) byType[e.type] += 1;
|
||||
}
|
||||
return { total: entries.length, byType };
|
||||
}
|
||||
|
||||
export function filterMemory(
|
||||
entries: MemoryEntryLike[],
|
||||
opts: { query?: string; type?: MemoryType | null },
|
||||
): MemoryEntryLike[] {
|
||||
const q = (opts.query ?? '').trim().toLowerCase();
|
||||
return entries.filter((e) => {
|
||||
if (opts.type && e.type !== opts.type) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
e.name.toLowerCase().includes(q) ||
|
||||
e.description.toLowerCase().includes(q) ||
|
||||
e.body.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user