sync: update from private repo (a67c053)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-17 01:04:08 +00:00
parent cbdfdeaa18
commit 138401ce1b
9 changed files with 489 additions and 49 deletions

View File

@ -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

View File

@ -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

View File

@ -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
View 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

View File

@ -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 && (

View File

@ -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}})",

View File

@ -163,6 +163,11 @@
"entriesTitle": "メモリエントリ",
"entriesSubtitle": "エージェントの毎セッションに注入される永続的な情報。",
"newEntry": "+ 新しいエントリ",
"filterAll": "すべて",
"searchPlaceholder": "メモリを検索(名前・説明・本文)",
"noMatches": "条件に一致するメモリはありません。",
"expand": "全文を表示",
"collapse": "折りたたむ",
"loading": "読み込み中…",
"loadFailed": "メモリエントリの読み込みに失敗しました: {{error}}",
"fetchFailed": "メモリエントリの読み込みに失敗しました ({{status}})",

View 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);
});
});

View 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)
);
});
}