feat(studio): add bilingual browser demo to documentation site (#581)

* feat(studio): add bilingual browser demo to documentation site

* fix(studio): preserve literal wikilinks during Markdown parsing

* fix(studio): preserve Markdown references and escaped wikilinks
This commit is contained in:
jinliyl 2026-10-03 22:34:11 +08:00 • committed by GitHub
parent 1648b7ce93
commit d529ec5256
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 2529 additions and 140 deletions

View file

@ -37,11 +37,26 @@ jobs:
with:
node-version: '22.22.3'
cache: npm
cache-dependency-path: github-pages/package-lock.json
cache-dependency-path: |
github-pages/package-lock.json
reme_studio/package-lock.json
- name: Install dependencies
run: npm ci
- name: Install Studio dependencies
run: npm ci --prefix ../reme_studio
- name: Check Studio types and lint
if: inputs.run_tests
working-directory: reme_studio
run: npm run format:check && npm run lint
- name: Test browser demo behavior
if: inputs.run_tests
working-directory: reme_studio
run: node --test tests/demo-workspace.test.mjs tests/wikilinks.test.mjs
- name: Run tests
if: inputs.run_tests
run: npm test
@ -49,6 +64,11 @@ jobs:
- name: Build documentation
run: npm run build
- name: Verify browser demo bundle
if: inputs.run_tests
working-directory: reme_studio
run: node --test tests/demo-build.test.mjs
- name: Configure Pages
if: inputs.upload_pages_artifact
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6

View file

@ -14,9 +14,7 @@ on:
- 'reme/config/default.yaml'
- 'integrations/claude_code/README.md'
- 'integrations/hermes_agent/README.md'
- 'reme_studio/README*.md'
- 'reme_studio/figures/**'
- 'reme_studio/public/og.jpg'
- 'reme_studio/**'
- 'integrations/dsh/README*.md'
- 'integrations/dsh/figures/**'
- 'integrations/openclaw/README*.md'

View file

@ -11,9 +11,7 @@ on:
- "integrations/hermes_agent/README.md"
- "README.md"
- "README_ZH.md"
- "reme_studio/README*.md"
- "reme_studio/figures/**"
- "reme_studio/public/og.jpg"
- "reme_studio/**"
- "integrations/dsh/README*.md"
- "integrations/dsh/figures/**"
- "integrations/openclaw/README*.md"

View file

@ -106,7 +106,7 @@ function nav(language: "zh" | "en"): DefaultTheme.NavItem[] {
return [
{ text: zh ? "首页" : "Home", link: `/${language}/` },
{ text: zh ? "文档" : "Docs", link: `/${language}/quick_start` },
{ text: "Studio", link: `/${language}/workspace/studio` },
{ text: zh ? "体验Studio" : "Try Studio", link: `/studio/?lang=${language}`, target: "_self" },
{ text: zh ? "集成" : "Integrations", link: `/${language}/integrations` },
{ text: zh ? "插件" : "Plugins", link: `/${language}/plugin_management` },
{ text: zh ? "评测" : "Benchmarks", link: `/${language}/benchmarks/longmemeval` },
@ -126,6 +126,7 @@ function docsSidebar(language: "zh" | "en"): DefaultTheme.SidebarItem[] {
{ text: zh ? "快速开始" : "Quick Start", link: `/${language}/quick_start` },
{ text: zh ? "基础配置" : "Configuration", link: `/${language}/configuration` },
{ text: zh ? "服务与部署" : "Services and Deployment", link: `/${language}/services` },
{ text: "ReMe Studio", link: `/${language}/workspace/studio` },
],
},
{

View file

@ -60,7 +60,7 @@ const translations = {
sectionTitle: "从记忆工作区,到自动研究",
sectionLead: "三个完整入口,把 ReMe 用到真实工作流中。",
products: [
{ mark: "▣", label: "WORKSPACE", title: "ReMe Studio", detail: "在本地 Web 工作区中浏览、编辑、搜索记忆,并探索 wikilink 图谱。", href: "/zh/workspace/studio", tone: "mint" },
{ mark: "▣", label: "WORKSPACE", title: "ReMe Studio", detail: "在本地 Web 工作区中浏览、编辑、搜索记忆,并探索 wikilink 图谱。", href: "/studio/?lang=zh", tone: "mint" },
{ mark: "◌", label: "DISCOVER", title: "Daily Paper", detail: "筛选值得阅读的论文,分析 PDF,并生成文件化笔记与五分钟简报。", href: "/zh/plugins/daily-paper", tone: "cyan" },
{ mark: "↗", label: "RESEARCH", title: "Auto Fin", detail: "连接最新财联社新闻与本地历史记忆,生成带 wikilink 的研究报告。", href: "/zh/plugins/auto-fin", tone: "amber" },
],
@ -120,7 +120,7 @@ const translations = {
sectionTitle: "From memory workspace to automated research",
sectionLead: "Three complete paths for putting ReMe into real workflows.",
products: [
{ mark: "▣", label: "WORKSPACE", title: "ReMe Studio", detail: "Browse, edit, and search memory in a local web workspace, then explore its wikilink graph.", href: "/en/workspace/studio", tone: "mint" },
{ mark: "▣", label: "WORKSPACE", title: "ReMe Studio", detail: "Browse, edit, and search memory in a local web workspace, then explore its wikilink graph.", href: "/studio/?lang=en", tone: "mint" },
{ mark: "◌", label: "DISCOVER", title: "Daily Paper", detail: "Select useful papers, analyze PDFs, and create file-native notes plus a five-minute brief.", href: "/en/plugins/daily-paper", tone: "cyan" },
{ mark: "↗", label: "RESEARCH", title: "Auto Fin", detail: "Connect recent CLS news with local memory to create traceable, wikilink-backed reports.", href: "/en/plugins/auto-fin", tone: "amber" },
],
@ -293,6 +293,7 @@ onMounted(async () => {
class="product-card"
:class="product.tone"
:href="localLink(product.href)"
:target="product.href.startsWith('/studio/') ? '_self' : undefined"
>
<span class="product-mark">{{ product.mark }}</span>
<span class="product-label">{{ product.label }}</span>

View file

@ -14,6 +14,7 @@ the build. Do not edit `.generated/` or `dist/`.
```bash
cd github-pages
npm ci
npm ci --prefix ../reme_studio
npm run dev
```
@ -29,13 +30,16 @@ npm run preview
```
The test suite verifies bilingual core pages, canonical-source mappings, generated Job coverage, and disposable output.
The production build is written to `github-pages/dist/` for the existing GitHub Pages workflow.
The production build is written to `github-pages/dist/` for the existing GitHub Pages workflow. Development and build
commands also compile the shared Studio UI in browser-demo mode and include it at `/studio/`; install both packages'
dependencies before running them. The demo needs no ReMe service or model credentials.
## Sources
- `docs/`: canonical guides, VitePress configuration, theme, and brand assets
- `reme/config/default.yaml`: generated callable Job reference
- `reme_studio/README*.md`: ReMe Studio
- `reme_studio/README*.md`: ReMe Studio documentation
- `reme_studio/demo/`: browser data adapter, bilingual Markdown examples, tags, and scripted conversations
- `integrations/{dsh,openclaw}/README*.md`: TypeScript host plugins
- `plugins/*/README*.md`: plugin guides
- `benchmark/*/README*.md`: benchmark guides

View file

@ -8,8 +8,8 @@
},
"scripts": {
"content": "node scripts/generate-content.mjs",
"dev": "npm run content && vitepress dev .generated/site",
"build": "npm run content && vitepress build .generated/site --outDir dist && node scripts/verify-build.mjs",
"dev": "npm run content && node scripts/build-studio.mjs && vitepress dev .generated/site",
"build": "npm run content && node scripts/build-studio.mjs && vitepress build .generated/site --outDir dist && node scripts/verify-build.mjs",
"preview": "vitepress preview .generated/site --outDir dist",
"test": "npm run content && node --test tests/*.test.mjs"
},

View file

@ -0,0 +1,12 @@
import { execFileSync } from "node:child_process";
import { cp, rm } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const studioDir = fileURLToPath(new URL("../../reme_studio/", import.meta.url));
const destination = new URL("../.generated/site/public/studio/", import.meta.url);
execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "build:demo"], {
cwd: studioDir,
stdio: "inherit",
});
await rm(destination, { recursive: true, force: true });
await cp(new URL("../../reme_studio/dist-demo/", import.meta.url), destination, { recursive: true });

View file

@ -60,6 +60,8 @@ const requiredFiles = [
"en/blog_20260920.html",
"zh/services.html",
"en/services.html",
"studio/index.html",
"studio/reme-icon.svg",
"zh/workspace/studio.html",
"en/workspace/studio.html",
"zh/reference/jobs.html",
@ -84,10 +86,30 @@ assert.match(ChineseHomepage, /89\.4%/);
assert.match(ChineseHomepage, /公开、透明的访问趋势/);
const ChineseStudio = await readFile(path.join(outputDir, "zh/workspace/studio.html"), "utf8");
assert.match(ChineseStudio, /class="VPLink link VPNavBarMenuLink active" href="\/zh\/workspace\/studio"/);
assert.match(ChineseStudio, /<span[^>]*>Studio<\/span>/);
assert.match(ChineseStudio, /href="\/studio\/\?lang=zh"/);
assert.match(ChineseStudio, /<span[^>]*>体验Studio<\/span>/);
assert.doesNotMatch(ChineseStudio, /<aside[^>]*class="VPSidebar/);
const EnglishStudio = await readFile(path.join(outputDir, "en/workspace/studio.html"), "utf8");
assert.match(EnglishStudio, /href="\/studio\/\?lang=en"/);
assert.match(EnglishStudio, /<span[^>]*>Try Studio<\/span>/);
// Studio is a separate static app: every entry must bypass VitePress routing.
for (const language of ["zh", "en"]) {
for (const page of ["index.html", "workspace/studio.html"]) {
const html = await readFile(path.join(outputDir, language, page), "utf8");
const links = [...html.matchAll(/<a\b[^>]*href="\/studio\/\?lang=[^"]+"[^>]*>/g)];
assert.ok(links.length, `${language}/${page}: Studio entry exists`);
for (const [link] of links) assert.match(link, /target="_self"/, "Studio must use full-page navigation");
}
}
const studioDemo = await readFile(path.join(outputDir, "studio/index.html"), "utf8");
assert.match(studioDemo, /<title>Try ReMe Studio<\/title>/);
for (const match of studioDemo.matchAll(/(?:src|href)="(\.\/[^"?#]+)"/g)) {
await access(path.join(outputDir, "studio", match[1]));
}
const ChineseTraffic = await readFile(path.join(outputDir, "zh/traffic.html"), "utf8");
assert.match(ChineseTraffic, /S1OZK1PSDLEpyiU5/);

View file

@ -144,8 +144,7 @@ test("tracks every generated input in documentation CI and deployment", async ()
"integrations/dsh/README*.md",
"integrations/dsh/figures/**",
"integrations/openclaw/README*.md",
"reme_studio/README*.md",
"reme_studio/figures/**",
"reme_studio/**",
"benchmark/toolmemory/gitcha.png",
];
for (const workflow of ["ci-docs.yml", "deploy-docs.yml"]) {

View file

@ -38,6 +38,9 @@ yarn-error.log*
/dist/
/dist-static/
/dist-demo/
!demo/content/*/daily/
!demo/content/*/daily/**
/.wrangler/
/outputs/
/work/

View file

@ -3,6 +3,7 @@
.vinext/
dist/
dist-static/
dist-demo/
out/
src/reme_studio/static/

View file

@ -14,6 +14,21 @@ indexes, catalogs, graphs, caches, and runtime metadata stay derived and rebuild
> All screenshots in this README were captured from a real local ReMe service with the Studio UI set to English. The
> isolated `Project Aurora` workspace is fictional and contains no user data, credentials, or private endpoints.
## Try in your browser
Open [Try Studio](https://reme.agentscope.io/studio/?lang=en) without installing or starting a backend. It reuses the
local Studio UI with 21 connected, fictional Aurora documents: resources, daily notes, durable knowledge, personal
preferences, and procedures.
Edit, save, and download files, explore wikilink graphs, and filter across directories with keywords and `memory_tags`.
Tag file counts and graphs are derived from current example files and update after saves. Multiple selected tags match
any selected tag. Changes and drafts stay in this browser, separately for each language. Reset demo restores the
starting content; clearing site data also removes changes, so download anything you want to keep.
Agent conversations are labeled scripts. Tool blocks read the current example files; answers do not regenerate after
edits. The demo runs no model, vector retrieval, automatic memory extraction, or background tasks and does not connect
to a local ReMe service. Follow the installation instructions below to use your real workspace.
## What Studio provides
| Area | Capabilities |
@ -369,6 +384,17 @@ intended edit, and save again. This conflict is deliberate protection against si
File features can work while chat is unavailable. Verify the configured Agent wrapper, model, API key, and provider
endpoint in the ReMe backend, then check Settings → Status. Do not place credentials in frontend environment variables.
## Develop the browser demo
```bash
npm run dev:demo
npm run build:demo
```
The demo builds to `dist-demo/` for static hosting. The GitHub Pages build includes it at `/studio/`. Example sources
live in `demo/content/{zh,en}/`; `demo/api.ts` derives files, tags, and graphs from those Markdown documents. Service
builds continue to use the HTTP API.
## Validation
Run proportionate checks from `reme_studio/`:

View file

@ -10,6 +10,18 @@ Studio 遵循 ReMe 的本地优先理念:Markdown 和其他工作区文件始
> 本 README 中的所有截图均来自真实运行的本地 ReMe 服务,Studio 界面统一设置为英文。截图使用隔离的虚构 `Project Aurora` 工作区,不包含用户数据、凭证或私有服务地址。
## 浏览器体验
打开 [体验Studio](https://reme.agentscope.io/studio/?lang=zh),无需安装或启动后端。它复用本地 Studio 的界面,
提供 21 篇相互关联的虚构 Aurora 项目文档:原始资料、日记、长期知识、个人偏好和操作流程。
可以编辑、保存、下载文件,探索 wikilink 图谱,并用关键词和 `memory_tags` 跨目录筛选。标签文件数和图谱
从当前示例文件计算,保存后更新;多标签筛选匹配任意一个所选标签。修改与草稿按语言保存在当前浏览器,
「重置示例」恢复初始内容。清除网站数据也会清除修改,请下载需要保留的文件。
Agent 对话是明确标注的预设演示,工具块读取当前示例文件;回答不会随编辑重新生成。演示不运行模型、
向量检索、自动记忆提炼或后台任务,也不连接本机 ReMe 服务。要使用自己的真实工作区,请按下面的安装说明启动 ReMe。
## Studio 提供什么
| 区域 | 能力 |
@ -328,6 +340,16 @@ VITE_REME_API_URL=http://127.0.0.1:2333 npm run dev:static
文件功能可在 Chat 不可用时正常工作。请检查 ReMe 后端配置的 Agent wrapper、模型、API key 和提供商地址,再查看 Settings → Status。不要把凭证放进前端环境变量。
## 开发浏览器演示
```bash
npm run dev:demo
npm run build:demo
```
演示构建输出到 `dist-demo/`,可作为静态目录托管;GitHub Pages 构建会将其放在 `/studio/`。示例源文件位于
`demo/content/{zh,en}/`,演示数据层位于 `demo/api.ts`,其文件、标签和图谱来自这些 Markdown。真实服务构建仍使用 HTTP API。
## 验证
在 `reme_studio/` 中运行与改动范围相称的检查:

View file

@ -1,6 +1,10 @@
import { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import dynamic from "next/dynamic";
import { IS_DEMO } from "../studio-mode";
import WorkspaceMarkdown from "./WorkspaceMarkdown";
const DemoTags = IS_DEMO
? dynamic(() => import("../../demo/DemoTags"), { ssr: false })
: null;
import { parseMarkdownFrontmatter } from "./markdown";
import styles from "./files-workspace.module.css";
@ -50,12 +54,18 @@ function MarkdownPreview({ content }: { content: string }) {
{entries.map(({ key, value }, index) => (
<div className={styles.frontmatterRow} key={`${key}:${index}`}>
<dt>{key}</dt>
<dd>{value}</dd>
<dd>
{key === "memory_tags" && DemoTags ? (
<DemoTags content={content} />
) : (
value
)}
</dd>
</div>
))}
</dl>
)}
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
<WorkspaceMarkdown content={body} />
</article>
);
}

View file

@ -18,13 +18,10 @@ import {
MessageSquarePlus,
Network,
} from "lucide-react";
import {
getAppConfig,
listWorkspaceFiles,
readWorkspaceFile,
REME_API_ENDPOINT,
} from "../api";
import { getAppConfig, listWorkspaceFiles, REME_API_ENDPOINT } from "../api";
import { useI18n } from "../i18n";
import { WORKSPACE_CHANGED } from "../studio-mode";
import { openWorkspaceFile } from "../open-file";
import { useWorkspaceStore } from "../store";
import type {
AppConfig,
@ -73,14 +70,7 @@ function DirectoryNode({
}) {
const { t } = useI18n();
const [expanded, setExpanded] = useState(depth === 0);
const {
tabs,
activeTabId,
openMarkdown,
openGraph,
hydrateMarkdown,
failMarkdown,
} = useWorkspaceStore();
const { tabs, activeTabId, openGraph } = useWorkspaceStore();
const active = tabs.find((tab) => tab.id === activeTabId);
const selected = active?.type === "markdown" && active.path === node.path;
if (node.type === "directory") {
@ -136,20 +126,7 @@ function DirectoryNode({
}
const open = async () => {
if (!isEditable(node.name)) return;
const existing = tabs.some(
(tab) => tab.type === "markdown" && tab.path === node.path,
);
const id = openMarkdown(node.path);
if (existing) return;
try {
const file = await readWorkspaceFile(node.path);
hydrateMarkdown(id, file.content, file.stat.mtime);
} catch (error) {
failMarkdown(
id,
error instanceof Error ? error.message : t("fileReadFailed"),
);
}
await openWorkspaceFile(node.path);
};
const absolutePath = workspaceDir
? absoluteWorkspacePath(workspaceDir, node.path)
@ -227,10 +204,14 @@ export default function FilesNavigator({
if (!document.hidden) void refresh();
};
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener(WORKSPACE_CHANGED, onVisibilityChange);
window.addEventListener("storage", onVisibilityChange);
return () => {
mounted = false;
window.clearInterval(timer);
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener(WORKSPACE_CHANGED, onVisibilityChange);
window.removeEventListener("storage", onVisibilityChange);
};
}, []);
const visiblePaths = useMemo(

View file

@ -0,0 +1,32 @@
import Editor from "@monaco-editor/react";
import "../monaco-setup";
import { useThemeStore } from "../theme";
import { getLanguage } from "./get-language";
export default function MarkdownEditor({
path,
content,
onChange,
}: {
path: string;
content: string;
onChange: (content: string) => void;
}) {
const theme = useThemeStore((state) => state.resolved);
return (
<Editor
path={path}
language={getLanguage(path)}
value={content}
theme={theme === "dark" ? "vs-dark" : "vs"}
onChange={(value) => onChange(value ?? "")}
options={{
minimap: { enabled: false },
fontSize: 13,
wordWrap: "on",
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
);
}

View file

@ -16,9 +16,10 @@ import {
ZoomIn,
ZoomOut,
} from "lucide-react";
import { getGraphSnapshot, readWorkspaceFile } from "../api";
import { getGraphSnapshot } from "../api";
import { useI18n } from "../i18n";
import { useWorkspaceStore } from "../store";
import { WORKSPACE_CHANGED } from "../studio-mode";
import { openWorkspaceFile } from "../open-file";
import type { GraphSnapshot, MemoryGraphRoot } from "../types";
import {
edgePath,
@ -75,10 +76,6 @@ export default function MemoryGraphView({ root }: { root: MemoryGraphRoot }) {
const [draggingId, setDraggingId] = useState("");
const dragSession = useRef<DragSession | undefined>(undefined);
const didDrag = useRef(false);
const tabs = useWorkspaceStore((state) => state.tabs);
const openMarkdown = useWorkspaceStore((state) => state.openMarkdown);
const hydrateMarkdown = useWorkspaceStore((state) => state.hydrateMarkdown);
const failMarkdown = useWorkspaceStore((state) => state.failMarkdown);
useEffect(() => {
let mounted = true;
@ -109,10 +106,14 @@ export default function MemoryGraphView({ root }: { root: MemoryGraphRoot }) {
if (!document.hidden) void load();
};
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener(WORKSPACE_CHANGED, onVisibilityChange);
window.addEventListener("storage", onVisibilityChange);
return () => {
mounted = false;
window.clearInterval(timer);
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener(WORKSPACE_CHANGED, onVisibilityChange);
window.removeEventListener("storage", onVisibilityChange);
};
}, []);
@ -170,20 +171,7 @@ export default function MemoryGraphView({ root }: { root: MemoryGraphRoot }) {
const openFile = async (node: PositionedGraphNode) => {
if (!node.indexed || node.virtual) return;
const existing = tabs.some(
(tab) => tab.type === "markdown" && tab.path === node.path,
);
const id = openMarkdown(node.path);
if (existing) return;
try {
const file = await readWorkspaceFile(node.path);
hydrateMarkdown(id, file.content, file.stat.mtime);
} catch (openError) {
failMarkdown(
id,
openError instanceof Error ? openError.message : t("fileReadFailed"),
);
}
await openWorkspaceFile(node.path);
};
const startDrag = (event: ReactPointerEvent<SVGGElement>, nodeId: string) => {

View file

@ -1,18 +1,19 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Editor from "@monaco-editor/react";
import dynamic from "next/dynamic";
import { Check, Code2, Download, Eye, LoaderCircle, Save } from "lucide-react";
import { saveWorkspaceFile } from "../api";
import { useI18n } from "../i18n";
import "../monaco-setup";
import { useWorkspaceStore } from "../store";
import { useThemeStore } from "../theme";
import type { WorkspaceTab } from "../types";
import FilePreview from "./FilePreview";
import { getLanguage } from "./get-language";
import styles from "./files-workspace.module.css";
const MarkdownEditor = dynamic(() => import("./MarkdownEditor"), {
ssr: false,
});
type FileTab = Extract<WorkspaceTab, { type: "markdown" }>;
/** Minimal ReMe port of QwenPaw's TabbedEditor: preview-first, Monaco edit, download and Cmd/Ctrl+S. */
@ -24,7 +25,6 @@ export default function TabbedEditor({ tab }: { tab: FileTab }) {
const update = useWorkspaceStore((state) => state.updateMarkdown);
const markSaved = useWorkspaceStore((state) => state.markSaved);
const dirty = tab.content !== tab.savedContent;
const theme = useThemeStore((state) => state.resolved);
const save = useCallback(async () => {
if (!dirty || saving) return;
@ -137,19 +137,10 @@ export default function TabbedEditor({ tab }: { tab: FileTab }) {
{preview ? (
<FilePreview filePath={tab.path} content={tab.content} />
) : (
<Editor
<MarkdownEditor
path={tab.path}
language={getLanguage(tab.path)}
value={tab.content}
theme={theme === "dark" ? "vs-dark" : "vs"}
onChange={(value) => update(tab.id, value ?? "")}
options={{
minimap: { enabled: false },
fontSize: 13,
wordWrap: "on",
scrollBeyondLastLine: false,
automaticLayout: true,
}}
content={tab.content}
onChange={(content) => update(tab.id, content)}
/>
)}
</div>

View file

@ -0,0 +1,30 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { openWorkspaceFile } from "../open-file";
import { remarkWikilinks, workspaceLinkTarget } from "./wikilinks";
export default function WorkspaceMarkdown({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkWikilinks]}
components={{
a: ({ href, title, children }) => (
<a
href={href}
title={title}
onClick={(event) => {
const path = workspaceLinkTarget(href);
if (!path) return;
event.preventDefault();
void openWorkspaceFile(path);
}}
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
);
}

View file

@ -0,0 +1,149 @@
import type { CompileContext } from "mdast-util-from-markdown";
import type {
Effects,
State,
Token,
TokenizeContext,
} from "micromark-util-types";
import type { Processor } from "unified";
import type {} from "remark-parse";
declare module "mdast" {
interface PhrasingContentMap {
remeWikilink: { type: "remeWikilink"; value: string };
}
interface RootContentMap {
remeWikilink: PhrasingContentMap["remeWikilink"];
}
}
declare module "micromark-util-types" {
interface TokenTypeMap {
remeWikilink: "remeWikilink";
}
}
const WIKILINK_PATTERN =
/^\[\[([^[\]|#\n]+?)(?:#[^[\]|\n]+)?(?:\|([^[\]\n]+))?\]\]$/;
/** Keep Markdown punctuation inside a wikilink literal during inline parsing. */
function tokenizeWikilink(
this: TokenizeContext,
effects: Effects,
ok: State,
nok: State,
): State {
const serialize = this.sliceSerialize.bind(this);
let token: Token;
return start;
function start(code: number | null) {
if (code !== 91) return nok(code);
token = effects.enter("remeWikilink");
effects.consume(code);
return secondBracket;
}
function secondBracket(code: number | null) {
if (code !== 91) return nok(code);
effects.consume(code);
return inside;
}
function inside(code: number | null): State | undefined {
// Micromark uses negative codes for line endings, tabs, and virtual spaces.
if (code === null || code <= -3 || code === 91) return nok(code);
effects.consume(code);
return code === 93 ? closingBracket : inside;
}
function closingBracket(code: number | null) {
if (code !== 93) return nok(code);
effects.consume(code);
effects.exit("remeWikilink");
const match = WIKILINK_PATTERN.exec(serialize(token));
return match?.[1].trim() ? ok : nok;
}
}
function enterWikilink(this: CompileContext, token: Token) {
// A dedicated node also keeps GFM's autolink transform out of literal aliases.
this.enter(
{ type: "remeWikilink", value: this.sliceSerialize(token) },
token,
);
}
function exitWikilink(this: CompileContext, token: Token) {
this.exit(token);
}
interface MarkdownNode {
type: string;
value?: string;
url?: string;
children?: MarkdownNode[];
}
/** Ignore malformed fragments authored directly in Markdown. */
export function workspaceLinkTarget(href?: string): string | undefined {
const prefix = "#reme-file=";
if (!href?.startsWith(prefix)) return;
try {
return decodeURIComponent(href.slice(prefix.length)) || undefined;
} catch {
return;
}
}
/** Resolve literal workspace wikilinks without rewriting code or existing links. */
export function remarkWikilinks(this: Processor) {
const data = this.data();
(data.micromarkExtensions ||= []).push({
text: { 91: { name: "remeWikilink", tokenize: tokenizeWikilink } },
});
(data.fromMarkdownExtensions ||= []).push({
enter: { remeWikilink: enterWikilink },
exit: { remeWikilink: exitWikilink },
});
return (tree: MarkdownNode) => {
const literalize = (node: MarkdownNode) => {
if (node.type === "remeWikilink") node.type = "text";
node.children?.forEach(literalize);
};
const visit = (node: MarkdownNode) => {
if (
!node.children ||
[
"link",
"linkReference",
"image",
"imageReference",
"code",
"inlineCode",
].includes(node.type)
) {
literalize(node);
return;
}
node.children = node.children.map((child) => {
if (child.type !== "remeWikilink" || !child.value) {
visit(child);
return child;
}
const match = WIKILINK_PATTERN.exec(child.value);
if (!match?.[1].trim()) {
literalize(child);
return child;
}
const target = match[1].trim();
return {
type: "link",
url: `#reme-file=${encodeURIComponent(target)}`,
children: [{ type: "text", value: match[2]?.trim() || target }],
};
});
};
visit(tree);
};
}

View file

@ -1,11 +1,29 @@
"use client";
import { create } from "zustand";
import { IS_DEMO } from "./studio-mode.ts";
export type Language = "zh" | "en";
const messages = {
zh: {
demoCloseSearch: "关闭搜索",
demoTitle: "浏览器演示",
demoDescription: "示例文件 · 修改保存在此浏览器 · 无需服务",
demoGuide: "体验指南",
demoSearch: "搜索 / 标签",
demoReset: "重置示例",
demoResetConfirm:
"重置当前语言的示例?此浏览器中的示例修改、草稿和对话将被清除。",
demoSearchDescription:
"关键词与 memory tags 跨目录筛选;演示使用浏览器关键词匹配。",
demoSearchPlaceholder: "搜索内容或路径,例如:离线",
demoTagHint: "文件数 · 多标签匹配任意一个",
demoClearTags: "清除标签",
demoMatches: "{count} 篇文档",
demoNoResults: "没有匹配的文档,试试其他关键词或标签。",
demoChatDescription: "预设对话展示标签检索、文件读取与引用。未运行模型。",
demoChatHint: "示例回答不会随文档编辑重新生成。",
memoryWorkspace: "记忆工作区",
newAgentChat: "新建 Agent 对话",
newConversation: "新对话",
@ -80,7 +98,7 @@ const messages = {
lightTheme: "浅色",
darkTheme: "深色",
systemTheme: "跟随系统",
documentation: "文档资料",
officialWebsite: "ReMe 官网",
github: "GitHub",
settings: "设置",
settingsTitle: "ReMe 设置",
@ -138,6 +156,25 @@ const messages = {
agentUnavailable: "无法连接 Agent(HTTP {status})",
},
en: {
demoCloseSearch: "Close search",
demoTitle: "Browser demo",
demoDescription:
"Example files · Changes stay in this browser · No server needed",
demoGuide: "Start here",
demoSearch: "Search / Tags",
demoReset: "Reset demo",
demoResetConfirm:
"Reset this language’s demo? Edited example files, drafts, and chats in this browser will be cleared.",
demoSearchDescription:
"Filter across directories by keywords and memory tags. This demo uses browser keyword matching.",
demoSearchPlaceholder: "Search content or paths, e.g. offline",
demoTagHint: "File counts · Match any selected tag",
demoClearTags: "Clear tags",
demoMatches: "{count} documents",
demoNoResults: "No matches. Try another keyword or tag.",
demoChatDescription:
"Scripted conversations show tag searches, file reads, and citations. No model is running.",
demoChatHint: "Scripted answers do not regenerate after edits.",
memoryWorkspace: "Memory workspace",
newAgentChat: "New Agent chat",
newConversation: "New chat",
@ -215,7 +252,7 @@ const messages = {
lightTheme: "Light",
darkTheme: "Dark",
systemTheme: "System",
documentation: "Documentation",
officialWebsite: "ReMe Website",
github: "GitHub",
settings: "Settings",
settingsTitle: "ReMe Settings",
@ -300,21 +337,44 @@ const applyLanguage = (language: Language) => {
document.documentElement.lang = language === "zh" ? "zh-CN" : "en";
};
function initialLanguage(): Language {
if (typeof window === "undefined") return "zh";
const requested = IS_DEMO
? new URLSearchParams(window.location.search).get("lang")
: null;
let saved: string | null = null;
try {
saved = localStorage.getItem("reme-language");
} catch {
/* Use the browser language. */
}
const preferred = requested || saved;
return preferred === "zh" || preferred === "en"
? preferred
: navigator.language.startsWith("zh")
? "zh"
: "en";
}
export const useLanguageStore = create<LanguageState>((set) => ({
language: "zh",
language: IS_DEMO ? initialLanguage() : "zh",
hydrate: () => {
const saved = localStorage.getItem("reme-language");
const language: Language =
saved === "zh" || saved === "en"
? saved
: navigator.language.startsWith("zh")
? "zh"
: "en";
const language = initialLanguage();
applyLanguage(language);
set({ language });
},
setLanguage: (language) => {
localStorage.setItem("reme-language", language);
try {
localStorage.setItem("reme-language", language);
} catch {
/* Keep the preference in memory. */
}
if (IS_DEMO) {
const url = new URL(window.location.href);
url.searchParams.set("lang", language);
window.location.assign(url.href);
return;
}
applyLanguage(language);
set({ language });
},

View file

@ -0,0 +1,21 @@
import { readWorkspaceFile } from "./api";
import { useWorkspaceStore } from "./store";
/** Open one workspace file consistently from the navigator, graph, or a link. */
export async function openWorkspaceFile(path: string) {
const store = useWorkspaceStore.getState();
const existing = store.tabs.some(
(tab) => tab.type === "markdown" && tab.path === path,
);
const id = store.openMarkdown(path);
if (existing) return;
try {
const file = await readWorkspaceFile(path);
store.hydrateMarkdown(id, file.content, file.stat.mtime);
} catch (error) {
store.failMarkdown(
id,
error instanceof Error ? error.message : "Failed to read file",
);
}
}

View file

@ -1,6 +1,8 @@
"use client";
import { create } from "zustand";
import { IS_DEMO } from "./studio-mode";
import { useLanguageStore } from "./i18n";
import { createJSONStorage, persist } from "zustand/middleware";
import {
applyStreamChunk,
@ -54,7 +56,9 @@ interface WorkspaceState {
const fileTitle = (path: string) => path.split("/").pop() || path;
// Preserve the original key so existing tabs, chats, and unsaved drafts survive the Studio rename.
const WORKSPACE_STORAGE_KEY = "reme-workspace";
const WORKSPACE_STORAGE_KEY = IS_DEMO
? `reme-demo-tabs-v1-${useLanguageStore.getState().language}`
: "reme-workspace";
export const useWorkspaceStore = create<WorkspaceState>()(
persist(

View file

@ -0,0 +1,2 @@
export const IS_DEMO = process.env.NEXT_PUBLIC_REME_DEMO === "true";
export const WORKSPACE_CHANGED = "reme-workspace-changed";

View file

@ -2,8 +2,9 @@
import { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import WorkspaceMarkdown from "./files-workspace/WorkspaceMarkdown";
import { IS_DEMO } from "./studio-mode";
import { openWorkspaceFile } from "./open-file";
import { SparkMenuExpandLine, SparkMenuFoldLine } from "@agentscope-ai/icons";
import {
BookOpenText,
@ -40,9 +41,15 @@ import {
appendWorkspaceFileReference,
WORKSPACE_FILE_DRAG_TYPE,
} from "./workspace-drag";
import SettingsCenter from "./settings-center";
const SettingsCenter = IS_DEMO
? null
: dynamic(() => import("./settings-center"), { ssr: false });
const DemoBar = IS_DEMO
? dynamic(() => import("../demo/DemoBar"), { ssr: false })
: null;
import { hasUnsavedChanges, unsavedTabsClosedBy } from "./tab-close";
import packageJson from "../package.json";
import { scenarios } from "../demo/scenarios";
const TabbedEditor = dynamic(() => import("./files-workspace/TabbedEditor"), {
ssr: false,
@ -239,7 +246,7 @@ function Tabs() {
function MarkdownView({ content }: { content: string }) {
return (
<article className="markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
<WorkspaceMarkdown content={content} />
</article>
);
}
@ -356,7 +363,7 @@ function ChatBlockView({
}
function Chat({ tab }: { tab: Extract<WorkspaceTab, { type: "agent" }> }) {
const { t } = useI18n();
const { language, t } = useI18n();
const [input, setInput] = useState("");
const [fileDragOver, setFileDragOver] = useState(false);
const endRef = useRef<HTMLDivElement>(null);
@ -369,6 +376,19 @@ function Chat({ tab }: { tab: Extract<WorkspaceTab, { type: "agent" }> }) {
}, [tab.messages]);
useEffect(() => () => controller.current?.abort(), []);
const suggestions = (
<div className="suggestions">
{(IS_DEMO
? scenarios[language].map((scenario) => scenario.question)
: [t("promptRecent"), t("promptTasks"), t("promptIdeas")]
).map((question) => (
<button key={question} onClick={() => setInput(question)}>
{question}
</button>
))}
</div>
);
const send = async () => {
const query = input.trim();
if (!query || tab.streaming) return;
@ -406,16 +426,8 @@ function Chat({ tab }: { tab: Extract<WorkspaceTab, { type: "agent" }> }) {
<div className="chat-empty">
<div className="agent-logo" aria-hidden="true" />
<h1>{t("chatTitle")}</h1>
<p>{t("chatDescription")}</p>
<div className="suggestions">
{[t("promptRecent"), t("promptTasks"), t("promptIdeas")].map(
(text) => (
<button key={text} onClick={() => setInput(text)}>
{text}
</button>
),
)}
</div>
<p>{t(IS_DEMO ? "demoChatDescription" : "chatDescription")}</p>
{suggestions}
</div>
)}
{tab.messages.map((message) => (
@ -451,6 +463,7 @@ function Chat({ tab }: { tab: Extract<WorkspaceTab, { type: "agent" }> }) {
</div>
</div>
))}
{IS_DEMO && tab.messages.length > 0 && suggestions}
<div ref={endRef} />
</div>
<div
@ -495,7 +508,7 @@ function Chat({ tab }: { tab: Extract<WorkspaceTab, { type: "agent" }> }) {
<Send size={17} />
</button>
</div>
<span>{t("composerHint")}</span>
<span>{t(IS_DEMO ? "demoChatHint" : "composerHint")}</span>
</div>
</div>
);
@ -516,6 +529,9 @@ function Workspace() {
let mounted = true;
void Promise.resolve(useWorkspaceStore.persist.rehydrate()).then(
async () => {
if (IS_DEMO && !useWorkspaceStore.getState().tabs.length && mounted) {
await openWorkspaceFile("START_HERE.md");
}
const restoredFiles = useWorkspaceStore
.getState()
.tabs.filter(
@ -648,14 +664,14 @@ function Workspace() {
: active?.title || t("workspace")}
</span>
<div className="topbar-actions">
<nav className="resource-links" aria-label={t("documentation")}>
<nav className="resource-links" aria-label={t("officialWebsite")}>
<a
href="https://reme.agentscope.io"
target="_blank"
rel="noreferrer"
>
<BookOpenText size={17} />
<span>{t("documentation")}</span>
<span>{t("officialWebsite")}</span>
</a>
<i aria-hidden="true" />
<a
@ -687,16 +703,19 @@ function Workspace() {
</button>
</div>
<ThemeMenu />
<button
className="settings-trigger"
onClick={() => setSettingsOpen(true)}
aria-label={t("settings")}
title={t("settings")}
>
<Settings size={18} />
</button>
{SettingsCenter && (
<button
className="settings-trigger"
onClick={() => setSettingsOpen(true)}
aria-label={t("settings")}
title={t("settings")}
>
<Settings size={18} />
</button>
)}
</div>
</header>
{DemoBar && <DemoBar />}
<div className="surface">
<FilesNavigator
open={navOpen}
@ -736,10 +755,12 @@ function Workspace() {
</div>
</section>
</div>
<SettingsCenter
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
{SettingsCenter && (
<SettingsCenter
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
)}
</main>
);
}

View file

@ -0,0 +1,157 @@
import { useEffect, useRef, useState } from "react";
import { BookOpen, Network, RotateCcw, Search, X } from "lucide-react";
import { useI18n } from "../app/i18n";
import { openWorkspaceFile } from "../app/open-file";
import { useWorkspaceStore } from "../app/store";
import { WORKSPACE_CHANGED } from "../app/studio-mode";
import { demoWorkspace } from "./api";
import { DEMO_SEARCH } from "./DemoTags";
import { WELCOME_PATH } from "./workspace";
import styles from "./demo.module.css";
export default function DemoBar() {
const { language, t } = useI18n();
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [, setRevision] = useState(0);
const [error, setError] = useState("");
const dialog = useRef<HTMLDialogElement>(null);
const workspace = demoWorkspace();
const results = workspace.search(query, selected);
const tags = workspace.tags();
const refresh = () => setRevision((value) => value + 1);
useEffect(() => {
const search = (event: Event) => {
setQuery("");
setSelected([(event as CustomEvent<string>).detail]);
dialog.current?.showModal();
};
window.addEventListener(WORKSPACE_CHANGED, refresh);
window.addEventListener("storage", refresh);
window.addEventListener(DEMO_SEARCH, search);
return () => {
window.removeEventListener(WORKSPACE_CHANGED, refresh);
window.removeEventListener("storage", refresh);
window.removeEventListener(DEMO_SEARCH, search);
};
}, []);
const reset = () => {
if (!window.confirm(t("demoResetConfirm"))) return;
try {
workspace.reset();
useWorkspaceStore.persist.clearStorage();
window.location.reload();
} catch (error) {
setError(error instanceof Error ? error.message : t("unknownError"));
}
};
const showFile = (path: string) => {
dialog.current?.close();
void openWorkspaceFile(path);
};
return (
<>
<div className={styles.bar}>
<div className={styles.description}>
<strong>{t("demoTitle")}</strong>
<span>{t("demoDescription")}</span>
</div>
<div className={styles.actions}>
<button onClick={() => void openWorkspaceFile(WELCOME_PATH)}>
<BookOpen size={14} />
{t("demoGuide")}
</button>
<button
onClick={() => useWorkspaceStore.getState().openGraph("wiki")}
>
<Network size={14} />
{t("memoryGraphShort")}
</button>
<button onClick={() => dialog.current?.showModal()}>
<Search size={14} />
{t("demoSearch")}
</button>
<button onClick={reset}>
<RotateCcw size={14} />
{t("demoReset")}
</button>
</div>
{error && <span role="alert">{error}</span>}
</div>
<dialog
ref={dialog}
className={styles.dialog}
aria-labelledby="demo-search-title"
>
<header>
<div>
<h2 id="demo-search-title">{t("demoSearch")}</h2>
<p>{t("demoSearchDescription")}</p>
</div>
<button
onClick={() => dialog.current?.close()}
aria-label={t("demoCloseSearch")}
>
<X size={18} />
</button>
</header>
<label className={styles.search}>
<Search size={16} />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("demoSearchPlaceholder")}
aria-label={t("demoSearchPlaceholder")}
/>
</label>
<div className={styles.tagHeading}>
<strong>memory_tags</strong>
<span>{t("demoTagHint")}</span>
{selected.length > 0 && (
<button onClick={() => setSelected([])}>
{t("demoClearTags")}
</button>
)}
</div>
<div className={styles.tags}>
{tags.map(({ tag, count }) => (
<button
key={tag}
className={styles.tag}
aria-pressed={selected.includes(tag)}
onClick={() =>
setSelected((current) =>
current.includes(tag)
? current.filter((value) => value !== tag)
: [...current, tag],
)
}
>
{tag}
<span>{count}</span>
</button>
))}
</div>
<p className={styles.count} role="status">
{t("demoMatches", { count: String(results.length) })}
</p>
<div className={styles.results} lang={language}>
{results.map((file) => (
<button
className={styles.result}
key={file.path}
onClick={() => showFile(file.path)}
>
<strong>{file.title}</strong>
<code>{file.path}</code>
<p>{file.snippet}</p>
<span>{file.tags.join(" · ")}</span>
</button>
))}
{!results.length && <p>{t("demoNoResults")}</p>}
</div>
</dialog>
</>
);
}

View file

@ -0,0 +1,21 @@
import { memoryTags } from "./workspace";
import styles from "./demo.module.css";
export const DEMO_SEARCH = "reme-demo-search";
export default function DemoTags({ content }: { content: string }) {
return (
<span className={styles.tags}>
{memoryTags(content).map((tag) => (
<button
key={tag}
className={styles.tag}
onClick={() =>
window.dispatchEvent(new CustomEvent(DEMO_SEARCH, { detail: tag }))
}
>
{tag}
</button>
))}
</span>
);
}

131
reme_studio/demo/api.ts Normal file
View file

@ -0,0 +1,131 @@
import { translate, useLanguageStore } from "../app/i18n";
import { WORKSPACE_CHANGED } from "../app/studio-mode";
import type { AppConfig, StreamChunk } from "../app/types";
import { createDemoWorkspace, DEMO_STORAGE_KEY } from "./workspace";
import { scenarios } from "./scenarios";
const content = import.meta.glob<string>("./content/**/*.md", {
eager: true,
query: "?raw",
import: "default",
});
const workspaces = new Map<string, ReturnType<typeof createDemoWorkspace>>();
export function demoWorkspace() {
const language = useLanguageStore.getState().language;
if (!workspaces.has(language)) {
const prefix = `./content/${language}/`;
const seeds = Object.fromEntries(
Object.entries(content)
.filter(([path]) => path.startsWith(prefix))
.map(([path, text]) => [path.slice(prefix.length), text]),
);
let storage: Storage | undefined;
try {
storage = localStorage;
} catch {
/* Editing remains available in memory. */
}
workspaces.set(
language,
createDemoWorkspace(seeds, storage, `${DEMO_STORAGE_KEY}-${language}`),
);
}
return workspaces.get(language)!;
}
export const REME_API_ENDPOINT = translate(
useLanguageStore.getState().language,
"demoTitle",
);
export async function getAppConfig(): Promise<AppConfig> {
return {
app_name: "ReMe Studio Demo",
workspace_dir: "/demo",
daily_dir: "daily",
digest_dir: "digest",
resource_dir: "resources",
};
}
export async function listWorkspaceFiles(extensions: string[]) {
return {
paths: Object.keys(demoWorkspace().files()).filter((path) =>
extensions.includes(path.split(".").pop()!),
),
limited: false,
};
}
export async function readWorkspaceFile(path: string) {
return demoWorkspace().read(path);
}
export async function saveWorkspaceFile(
path: string,
content: string,
expectedMtime?: string,
) {
const stat = demoWorkspace().save(path, content, expectedMtime);
window.dispatchEvent(new Event(WORKSPACE_CHANGED));
return stat;
}
export async function getGraphSnapshot() {
return demoWorkspace().graph();
}
export async function streamChat(
query: string,
_sessionId: string | undefined,
signal: AbortSignal,
onChunk: (chunk: StreamChunk) => void,
) {
const language = useLanguageStore.getState().language;
const scenario = scenarios[language].find((item) => item.question === query);
const emit = (
chunk_type: StreamChunk["chunk_type"],
chunk: StreamChunk["chunk"],
extra = {},
) => {
signal.throwIfAborted();
onChunk({ chunk_type, chunk, done: chunk_type === "done", ...extra });
};
emit("reply_start", "");
if (scenario) {
emit(
"tool_call",
{ query: scenario.tag, tags: [scenario.tag] },
{ tool_call_id: "search", tool_call_name: "search" },
);
emit("tool_result", demoWorkspace().search(scenario.tag, [scenario.tag]), {
tool_call_id: "search",
tool_call_name: "search",
});
for (const path of scenario.paths) {
emit(
"tool_call",
{ path },
{ tool_call_id: path, tool_call_name: "read" },
);
emit("tool_result", demoWorkspace().read(path).content, {
tool_call_id: path,
tool_call_name: "read",
});
}
}
const answer =
scenario?.answer ||
(language === "zh"
? "这是预设对话演示,未运行模型。请选择上方的示例问题,或使用「搜索 / 标签」检索和编辑工作区。"
: "This is a scripted conversation without a model. Choose a sample question above, or use Search / Tags to explore and edit the workspace.");
for (const part of answer.match(/[\s\S]{1,24}/gu) || [answer]) {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener("abort", abort);
resolve();
}, 20);
function abort() {
clearTimeout(timer);
reject(signal.reason);
}
signal.addEventListener("abort", abort, { once: true });
});
emit("content", part);
}
emit("reply_end", "");
emit("done", "");
}

View file

@ -0,0 +1,29 @@
---
name: From one note to reusable memory
description: Explore Aurora resources, daily notes, durable knowledge, tags, and links.
memory_tags: ["aurora", "memory", "guide"]
---
# Try ReMe Studio
This fictional **Aurora team knowledge assistant** workspace turns scattered meetings, decisions, and experience into traceable, reusable memory. Open, edit, save, and download Markdown directly in your browser.
## 1. Follow a memory to its source
Open [[digest/wiki/local-first.md|the local-first decision]]. Its sources are [[resources/offline-feedback.md|field feedback]] and [[daily/2026-09-15/decision.md|the decision diary]]. The earlier [[resources/requirements.md|requirements draft]] still describes a cloud-first plan, so you can compare the old proposal with the current conclusion.
## 2. Find a topic across directories
Choose **Search / Tags** above and select `local_first`. Resources, daily notes, knowledge, and procedures share this topic. Add the keyword “offline” to narrow the results. Tags come from each file's `memory_tags` front matter, rather than a separate hand-maintained category list.
## 3. Edit your browser copy
Open [[digest/personal/communication.md|communication preferences]] and switch to Edit. Replace the tag `communication` with `writing`, save, and check the tag counts and results. Download exports the file; Reset demo restores the starting workspace.
## 4. Explore links and answers
Choose Graph beside a Knowledge category. Select a node to inspect its incoming and outgoing links, then open the source note. Start a Chat, send a sample question, and expand `search` and `read` to inspect the actual example files.
> Graphs and keyword filters run in your browser. Chat answers are scripted; editing a document does not generate a new model answer. Changes stay in this browser and disappear if site data is cleared.
To use your own workspace, follow the documentation above, run `reme start`, and open your local Studio.

View file

@ -0,0 +1,14 @@
---
name: September 14 daily index
description: Find the kickoff by date.
memory_tags: ["aurora", "planning", "daily"]
---
# 2026-09-14
Today the team created the Aurora workspace and completed the kickoff. This daily index provides chronological navigation; individual notes retain the detailed facts.
- [[daily/2026-09-14/kickoff.md|Kickoff diary]]: goals and owners.
- [[resources/kickoff.md|Original meeting record]]: the full agreement.
See [[digest/wiki/aurora.md|the Aurora overview]] for durable context. The `planning` tag also finds planning notes on other dates.

View file

@ -0,0 +1,15 @@
---
name: Kickoff and open assumptions
description: Record observations and retain unresolved proposals.
memory_tags: ["aurora", "planning", "daily"]
---
# Kickoff diary · 2026-09-14
The team agreed to make files and source links useful before expanding other features. Central cloud storage is still a candidate and has not passed field validation.
Zhou created resources, daily, digest/wiki, digest/personal, and digest/procedure. Lin stressed that stopping the service must not remove access to memory.
He will investigate connectivity and data restrictions tomorrow. We did not turn today's candidate into an established conclusion, so future readers can distinguish a proposal from a fact.
Sources: [[resources/kickoff.md|kickoff]] and [[resources/requirements.md|requirements]]. See [[digest/wiki/local-first.md|the architecture decision]] for the current conclusion.

View file

@ -0,0 +1,15 @@
---
name: Changing to local-first
description: New evidence updates durable memory.
memory_tags: ["aurora", "local_first", "decision"]
---
# Decision diary · 2026-09-15
After [[resources/offline-feedback.md|field feedback]], Lin and Zhou approved local-first operation: Markdown files are durable sources, and indexes and graphs are rebuilt from files.
This updates yesterday's cloud assumption. We retain [[resources/requirements.md|the original draft]] and write the current conclusion, reasons, and sources into [[digest/wiki/local-first.md|architecture knowledge]].
Cross-device synchronization moves beyond the pilot; see [[digest/wiki/sync.md|sync scope]]. Model reasoning still depends on the configured provider. Local-first does not mean every model runs on the device.
Next: Zhou prepares offline reading and index recovery checks, while He observes field usability.

View file

@ -0,0 +1,15 @@
---
name: Offline and tag validation
description: Check file ownership, topic filtering, and recovery.
memory_tags: ["aurora", "local_first", "release"]
---
# Validation diary · 2026-09-17
Zhou stopped the local service and confirmed that Markdown files remained readable and copyable. After restarting, he checked derived-index recovery from existing files. Source files were not deleted to repair an index.
Lin filtered by `local_first` and found field resources, the decision diary, and architecture knowledge on the same topic. Adding “offline” narrowed irrelevant results.
He suggested explaining the distinction between tag filters and wikilink graphs. Tags collect a topic; links express specific relationships. Both are derived from file content.
We added [[digest/wiki/memory-tags.md|the tag guide]] and [[digest/procedure/recovery.md|recovery steps]]. Before release, check [[digest/procedure/release-checklist.md|the checklist]].

View file

@ -0,0 +1,15 @@
---
name: September 18 daily index
description: Navigate by date to acceptance and review.
memory_tags: ["aurora", "release", "daily"]
---
# 2026-09-18
Today the pilot passed acceptance and moved to a five-person internal trial.
- [[daily/2026-09-18/review.md|Pilot review]]: experience worth retaining.
- [[resources/pilot-results.md|Original results]]: passed and untested checks.
- [[digest/wiki/release.md|Release status]]: the current conclusion and next steps.
This index preserves chronological access. Use the `release` tag for a view by topic.

View file

@ -0,0 +1,15 @@
---
name: Turn pilot experience into procedures
description: Capture repeated checks as reusable operating knowledge.
memory_tags: ["aurora", "release", "procedure"]
---
# Pilot review · 2026-09-18
[[resources/pilot-results.md|Acceptance results]] support an internal trial, but do not establish readiness for multi-device sync or large-scale retrieval. Conclusions must retain the scope of evidence.
The review found repeated release checks: file backups, source links, tags, and offline reading. We captured these in [[digest/procedure/release-checklist.md|a reusable release checklist]].
Index troubleshooting became a separate [[digest/procedure/recovery.md|recovery procedure]], with a clear rule: protect source files before repairing derived state.
Lin asked future updates to lead with conclusions and evidence, followed by action items. This also updates [[digest/personal/communication.md|communication preferences]].

View file

@ -0,0 +1,20 @@
---
name: Lin’s communication preferences
description: Traceable preferences support inspectable personal memory.
memory_tags: ["aurora", "personal", "communication"]
---
# Communication preferences
Lin wants project updates to lead with a conclusion, followed by sources and next steps. Do not describe a plan as completed work; mark unknowns explicitly.
## Reusable rules
- Keep an update to three paragraphs where possible.
- Attach an original-file link to significant conclusions.
- Name an owner and deadline for each action.
- State the verified scope without expanding performance claims.
Source: [[daily/2026-09-18/review.md|the pilot review]]. See [[digest/wiki/release.md|release status]] for an application and [[digest/wiki/people.md|owners]] for roles.
Try replacing `communication` with `writing` in the front matter. Save and inspect the new tag in the panel. Your edits belong only to this browser copy.

View file

@ -0,0 +1,15 @@
---
name: Working habits and data boundaries
description: Retain long-term habits that users can inspect and change.
memory_tags: ["aurora", "personal", "local_first"]
---
# Working habits
The team writes original records to files before summarizing them into durable knowledge. Internal material stays in the controlled workspace by default; sending it to an external model depends on the actual deployment and provider settings.
Lin records conclusions on the day of a meeting. Zhou checks recovery before releases. He uses field feedback to correct untested assumptions.
These habits come from [[resources/kickoff.md|kickoff agreements]] and [[resources/offline-feedback.md|field constraints]]. They help an agent understand context, while remaining inspectable, editable, and removable when outdated.
Related knowledge: [[digest/wiki/local-first.md|local-first]] and [[digest/personal/communication.md|communication style]].

View file

@ -0,0 +1,19 @@
---
name: Protect files and recover derived state
description: Protect user memory before repairing indexes and links.
memory_tags: ["aurora", "local_first", "procedure"]
---
# Recovery procedure
When retrieval results are missing, first check that source files exist, then inspect derived indexes. Do not delete or rewrite user memory to repair an index.
1. Preserve a workspace copy and verify paths and file content.
2. Check that the local ReMe service uses the expected workspace.
3. Inspect ingestion, search indexes, and wikilink graphs according to the issue.
4. `reindex` rebuilds search and tag indexes from ingested chunks. It does not scan the workspace or rebuild the graph; new or changed files still need normal ingestion.
5. Verify results with queries that expose their sources.
Source: [[daily/2026-09-17/validation.md|the validation diary]]. Principle: [[digest/wiki/local-first.md|files are the source of truth]].
This browser demo has no backend index. Its graph and tags are derived directly from current example files. Reset demo restores only this demo copy; it is not a production recovery tool.

View file

@ -0,0 +1,19 @@
---
name: Reusable release checklist
description: Turn acceptance experience into documented operating steps.
memory_tags: ["aurora", "release", "procedure"]
---
# Release checklist
For Aurora's internal pilot. [[digest/wiki/people.md|Zhou]] performs the checks and Lin confirms scope. Source: [[daily/2026-09-18/review.md|the review]].
- [ ] Preserve a workspace file copy and check direct Markdown access.
- [ ] Check source links in [[digest/wiki/local-first.md|the architecture decision]].
- [ ] Test cross-directory retrieval with `local_first` and `release` tags.
- [ ] Stop the service and open existing files offline.
- [ ] Check derived-data recovery using [[digest/procedure/recovery.md|the recovery steps]].
- [ ] Record passed, failed, and untested checks in the acceptance record.
- [ ] Update [[digest/wiki/release.md|release status]] with original evidence.
The checklist captures reusable experience. Checking an item does not start a background task. See [[resources/pilot-results.md|the acceptance record]] for current results.

View file

@ -0,0 +1,21 @@
---
name: Aurora project overview
description: Current goals, decisions, owners, and release progress.
memory_tags: ["aurora", "planning", "memory"]
---
# Aurora
Aurora is a five-person team's knowledge-assistant pilot. It helps members recover project context, retain reasons behind decisions, and reuse successful procedures.
## Current agreements
- Durable data lives in user-owned Markdown files; see [[digest/wiki/local-first.md|the local-first decision]].
- The first pilot excludes multi-device synchronization; see [[digest/wiki/sync.md|scope boundaries]].
- [[digest/wiki/memory-tags.md|Memory tags]] collect topics, while wikilinks express explicit file relationships.
## Owners and status
[[digest/wiki/people.md|Lin, Zhou, and He]] own product, engineering, and the field pilot. Internal trials began September 18; see [[digest/wiki/release.md|release status]] for the accepted scope.
This overview captures stable knowledge. Specific events remain in daily and original records in resources. Source: [[resources/kickoff.md|the kickoff]].

View file

@ -0,0 +1,19 @@
---
name: Local-first architecture decision
description: The current decision, reasons for changing it, and traceable sources.
memory_tags: ["aurora", "local_first", "decision"]
---
# Local-first
**Current decision: workspace Markdown is the durable source; indexes, catalogs, and graphs are rebuildable derived data.**
## Why the plan changed
[[resources/requirements.md|The September 14 draft]] proposed central cloud storage. [[resources/offline-feedback.md|September 15 field feedback]] revealed unreliable connectivity and restrictions on external sharing. [[daily/2026-09-15/decision.md|That day's decision]] approved local-first operation.
## What this means for users
Stopping the service does not prevent direct file reading. A damaged index should be rebuilt from source files; see [[digest/procedure/recovery.md|the recovery guide]]. If an online model is unavailable, model answers may be unavailable too. File ownership is unaffected.
Synchronization is planned separately in [[digest/wiki/sync.md|sync scope]]. This conclusion follows explicit evidence; do not mistake the early draft for the current plan.

View file

@ -0,0 +1,26 @@
---
name: Find a topic with memory tags
description: The tag field, normalization, file counts, and combined filtering.
memory_tags: ["aurora", "memory", "guide"]
---
# Memory tags
Tags live in the Markdown front matter's `memory_tags` list. ReMe derives its rebuildable tag index from files.
```yaml
memory_tags: [aurora, local_first, decision]
```
## Tags, directories, and links
Directories express file purpose. The `local_first` tag collects a topic across resources, daily, and digest. A [[digest/wiki/local-first.md|wikilink]] expresses a specific source or relationship.
## Try it
1. Select `local_first` in the search panel and inspect matches across directories and the file count.
2. Also select `release`: multiple tags match **any** selected tag, like the real search Job's tags filter.
3. Add “offline”: keywords and tags jointly constrain the results.
4. Edit the tags in [[digest/personal/communication.md|communication preferences]] and save; counts update immediately.
The demo accepts inline and indented lists, replaces whitespace with underscores, lowercases and deduplicates tags, and keeps up to three valid tags per file. It runs keyword filtering; ReMe's hybrid vector and BM25 retrieval requires a service.

View file

@ -0,0 +1,17 @@
---
name: Project roles and owners
description: Give tasks and decisions explicit owners.
memory_tags: ["aurora", "planning", "memory"]
---
# Owners
| Member | Role | Responsibility |
| --------- | ----------- | ----------------------------------- |
| Lin Cheng | Product | Requirements, scope, and acceptance |
| Zhou Ning | Engineering | Files, indexes, and recovery |
| He Yue | Pilot | Field checks and feedback |
Source: [[resources/kickoff.md|the kickoff]]. See [[digest/wiki/aurora.md|the overview]] for context and [[digest/procedure/release-checklist.md|release procedures]] for upcoming checks.
Lin prefers brief updates with evidence; see [[digest/personal/communication.md|communication preferences]]. Roles belong in durable knowledge, while daily actions remain in the diary.

View file

@ -0,0 +1,19 @@
---
name: Pilot release status
description: Keep current status with original evidence and remaining work.
memory_tags: ["aurora", "release", "memory"]
---
# Release status
As of 2026-09-18, Aurora is approved for a five-person internal trial. Evidence: [[resources/pilot-results.md|acceptance results]] and [[daily/2026-09-18/review.md|the review]].
## Verified
Offline reading, tags across directories, source traceability, and restoring derived data from files. Each release continues to use [[digest/procedure/release-checklist.md|the checklist]].
## Not verified
Multi-device synchronization, large-scale performance, and long-term effectiveness. He collects next week's feedback, Zhou handles file and index issues, and Lin determines the next acceptance scope.
Updates follow [[digest/personal/communication.md|communication preferences]]: lead with the conclusion, then evidence, and distinguish passed checks from checks not performed.

View file

@ -0,0 +1,19 @@
---
name: Synchronization scope
description: Distinguish deferred work from the accepted pilot scope.
memory_tags: ["aurora", "local_first", "planning"]
---
# Synchronization scope
Aurora's first pilot promises a single-device workspace, without automatic multi-device sync. [[daily/2026-09-15/decision.md|The local-first decision]] confirms this boundary.
## Future questions
Concurrent saves across devices, duplicate records, sensitive-data sync scope, and restoring previous versions still need designs. The current pilot did not validate these problems.
## Current operation
Users can download Markdown copies, but manual export is not a synchronization mechanism. Saving in this browser demo also belongs only to this browser and is not a backup of a real workspace.
See [[digest/wiki/release.md|release status]] for acceptance and [[digest/wiki/local-first.md|architecture knowledge]] for data-ownership principles.

View file

@ -0,0 +1,25 @@
---
name: Kickoff meeting
description: Roles, pilot scope, and open questions.
memory_tags: ["aurora", "planning", "resource"]
---
# Kickoff · 2026-09-14
Attendees: Lin Cheng (product), Zhou Ning (engineering), and He Yue (pilot). Lin owns requirements and acceptance, Zhou owns files and indexes, and He collects field feedback.
## Confirmed
The pilot uses a Markdown workspace with resources, daily, and digest directories. The team needs three views: events by time, conclusions by topic, and experience as reusable procedures.
## Open questions
He will check network availability at two sites. Zhou will verify that files remain readable when the service stops. Lin will prepare ten questions with known original sources to test traceability.
## Next actions
- Confirm deployment after field feedback on September 15.
- Test offline reading, tag filters, and recovery on September 17.
- Decide whether to proceed after the September 18 review.
References: [[resources/requirements.md|requirements]], [[digest/wiki/people.md|owners]], and [[daily/2026-09-14/kickoff.md|the daily record]].

View file

@ -0,0 +1,19 @@
---
name: Offline field feedback
description: Unreliable site connectivity changes the deployment assumptions.
memory_tags: ["aurora", "local_first", "resource"]
---
# Field feedback · 2026-09-15
He visited two pilot sites. Site A lost connectivity in a meeting room for about 20 minutes. Site B cannot send internal material to an external service. Both need to read existing records while offline.
## Observations and constraints
An online model may still perform reasoning tasks, but basic file reading, editing, and retrieval must not assume public internet access. Users need to inspect exactly which material they have saved.
## Recommendation
Use local workspace files as durable sources and treat indexes as rebuildable data. Make cross-device synchronization a later, explicit option so it does not block this pilot.
This recommendation was approved in [[daily/2026-09-15/decision.md|the decision diary]] and integrated into [[digest/wiki/local-first.md|durable architecture knowledge]]. It revises [[resources/requirements.md|the draft's cloud assumption]].

View file

@ -0,0 +1,21 @@
---
name: Pilot acceptance results
description: Record passed checks, remaining issues, and the scope of evidence.
memory_tags: ["aurora", "release", "resource"]
---
# Acceptance results · 2026-09-18
These are fictional Aurora pilot results, not a ReMe performance benchmark.
| Check | Result | Evidence |
| ------------------------ | ---------- | ------------------------------------------------------- |
| Offline Markdown reading | Passed | An editor opens files while the service is stopped |
| Tags across directories | Passed | `local_first` finds feedback, decisions, and procedures |
| Source traceability | Passed | Architecture conclusions link to field feedback |
| Index recovery | Passed | Derived data is restored from existing files |
| Multi-device sync | Not tested | Outside the first pilot |
Lin approved an internal five-person trial. He will collect feedback for a week. Cross-device conflicts and interaction hints when online models are unavailable remain follow-up work.
The conclusion appears in [[digest/wiki/release.md|release status]]. See [[digest/procedure/release-checklist.md|the release checklist]] for steps and [[daily/2026-09-18/review.md|the review diary]] for the process.

View file

@ -0,0 +1,22 @@
---
name: Aurora requirements draft
description: Preserve the early cloud-first proposal for later comparison.
memory_tags: ["aurora", "planning", "resource"]
---
# Requirements draft · 2026-09-14
Aurora helps a five-person product team recover project context, meeting conclusions, and operating experience. The pilot supports Markdown; users own the files and can read them with an ordinary editor.
## Initial proposal
The first proposal used a central cloud database: upload meetings, generate knowledge centrally, and require an online connection for every query. This is a discussion draft, not an approved architecture. Field conditions still need validation.
## Acceptance goals
- Every significant conclusion leads back to an original record.
- A topic can be found across resources, daily notes, and durable knowledge.
- Users can export their memory without a proprietary database format.
- Later decisions retain the previous plan and the reason for changing it.
[[resources/kickoff.md|The kickoff]] records ownership. Field feedback changed the architecture; see [[digest/wiki/local-first.md|the current local-first decision]]. This draft preserves history, not the current architecture.

View file

@ -0,0 +1,29 @@
---
name: 从一条记录到可复用的记忆
description: 探索 Aurora 的资料、日记、长期知识、标签与关联图谱。
memory_tags: ["aurora", "memory", "guide"]
---
# 欢迎体验 ReMe Studio
这里是虚构的 **Aurora 团队知识助手**工作区。团队希望把散落的会议、决策和经验变成可追溯、可复用的记忆。你可以在浏览器里打开、编辑、保存和下载这些 Markdown,无需启动服务。
## 1. 跟随一条记忆的来源
打开 [[digest/wiki/local-first.md|本地优先决策]]。这篇长期知识来自 [[resources/offline-feedback.md|现场反馈]] 与 [[daily/2026-09-15/decision.md|决策日记]]。早期 [[resources/requirements.md|需求草案]] 仍保留云端方案,因此你可以看到旧计划和新结论的区别。
## 2. 用标签跨目录查找
点击顶部 **搜索 / 标签**,选择 `local_first`。同一个主题会同时找到资料、日记、知识和流程;再输入「离线」,缩小到包含这个词的文件。标签来自每篇文件 front matter 中的 `memory_tags`,不是另一份手工维护的分类。
## 3. 修改自己的浏览器副本
打开 [[digest/personal/communication.md|沟通偏好]],切换到 Edit。将标签 `communication` 改为 `writing`,保存,再查看标签数量和筛选结果。下载按钮可以导出文件;「重置示例」恢复最初的内容。
## 4. 探索关系与回答
在 Knowledge 分类旁点击 Graph,选中节点查看入链、出链,再打开源文件。创建 Chat,发送预设问题,展开 `search` 和 `read` 查看实际示例文件。
> 图谱和关键词筛选在浏览器里计算。对话回答是预设演示,修改文档不会自动生成新的模型回答。数据仅保存在当前浏览器;清除网站数据会清除修改。
安装真实 ReMe:打开顶部文档,运行 `reme start`,在本机 Studio 使用自己的工作区。

View file

@ -0,0 +1,14 @@
---
name: 9 月 14 日日记索引
description: 按日期找到启动讨论。
memory_tags: ["aurora", "planning", "daily"]
---
# 2026-09-14
今天建立 Aurora 工作区并完成启动讨论。日记索引负责按时间导航,详细事实保留在独立记录中。
- [[daily/2026-09-14/kickoff.md|启动记录]]:确定首期目标与负责人。
- [[resources/kickoff.md|会议原始资料]]:查看完整约定。
长期项目背景见 [[digest/wiki/aurora.md|Aurora 概览]]。用 `planning` 标签也能找到其他日期的计划事项。

View file

@ -0,0 +1,15 @@
---
name: 启动与未决假设
description: 记录当天观察,保留尚未确定的方案。
memory_tags: ["aurora", "planning", "daily"]
---
# 启动记录 · 2026-09-14
团队确认:先把文件和来源关联做好,再扩展更多能力。云端集中存储仍是候选方案,尚未通过现场验证。
周宁创建 resources、daily、digest/wiki、digest/personal 和 digest/procedure。林澄强调,用户不能因为停止服务就失去对记忆的访问。
何悦将在明天调查网络与资料使用限制。今天没有把候选方案写成确定结论,避免以后把讨论误当成事实。
来源:[[resources/kickoff.md|启动会议]]、[[resources/requirements.md|需求草案]]。最新结论见 [[digest/wiki/local-first.md|架构决策]]。

View file

@ -0,0 +1,15 @@
---
name: 从云端假设转向本地优先
description: 新证据推动更新长期记忆。
memory_tags: ["aurora", "local_first", "decision"]
---
# 决策日记 · 2026-09-15
收到 [[resources/offline-feedback.md|现场反馈]] 后,林澄与周宁确认本地优先:Markdown 文件是持久来源,索引与图谱均可从文件重建。
这更新了昨天的云端假设。我们保留 [[resources/requirements.md|原始草案]],将当前结论写入 [[digest/wiki/local-first.md|架构知识]],并标明原因与来源。
跨设备同步推迟到试点之后,见 [[digest/wiki/sync.md|同步范围]]。模型推理仍取决于配置的提供商;本地优先并不意味着所有模型都在本机运行。
下一步:准备离线读取与索引恢复检查,由周宁执行,何悦观察现场可用性。

View file

@ -0,0 +1,15 @@
---
name: 离线与标签验证
description: 验证文件所有权、主题筛选与恢复流程。
memory_tags: ["aurora", "local_first", "release"]
---
# 验证记录 · 2026-09-17
周宁停止本地服务,确认 Markdown 仍可打开和复制。重启后,根据已有文件检查派生索引恢复。没有通过删除源文件来解决索引问题。
林澄用 `local_first` 标签筛选,发现现场资料、决策日记和架构知识都属于同一主题。再加关键词「离线」,能减少无关结果。
何悦建议:解释标签筛选和 wikilink 图谱的区别。标签用于主题集合,链接用于明确关系;两者都来自文件内容。
新增 [[digest/wiki/memory-tags.md|标签说明]] 与 [[digest/procedure/recovery.md|恢复流程]]。发布前还需核对 [[digest/procedure/release-checklist.md|检查清单]]。

View file

@ -0,0 +1,15 @@
---
name: 9 月 18 日日记索引
description: 按日期导航到验收与复盘。
memory_tags: ["aurora", "release", "daily"]
---
# 2026-09-18
今天完成试点验收,决定进入五人内部试用。
- [[daily/2026-09-18/review.md|试点复盘]]:哪些经验值得长期保留。
- [[resources/pilot-results.md|验收原始结果]]:哪些检查已通过,哪些尚未做。
- [[digest/wiki/release.md|发布状态]]:最新结论与下一阶段安排。
此索引保留时间入口;主题入口可以使用 `release` 标签。

View file

@ -0,0 +1,15 @@
---
name: 把试点经验沉淀为流程
description: 将重复检查沉淀为可复用的操作知识。
memory_tags: ["aurora", "release", "procedure"]
---
# 试点复盘 · 2026-09-18
[[resources/pilot-results.md|验收结果]] 支持内部试用,但不证明多设备同步或大规模检索已经就绪。结论必须保留证据范围。
复盘中发现,每次发布都需要重复核对文件备份、来源链接、标签和离线读取。因此将这些步骤沉淀为 [[digest/procedure/release-checklist.md|发布检查清单]]。
索引故障处理被单独整理到 [[digest/procedure/recovery.md|恢复流程]],核心原则是先保护源文件,再修复派生状态。
林澄要求后续汇报先写结论与证据,再写行动项。这也更新了 [[digest/personal/communication.md|沟通偏好]]。

View file

@ -0,0 +1,20 @@
---
name: 林澄的沟通偏好
description: 明确偏好来源,支持可检查的个性化记忆。
memory_tags: ["aurora", "personal", "communication"]
---
# 沟通偏好
林澄希望项目汇报先给结论,再给来源与下一步。避免把计划写成已完成事实;未知项应明确标注。
## 可复用规则
- 每次汇报尽量控制在三段以内。
- 重要结论附一个原始文件链接。
- 行动项明确负责人和截止日期。
- 给出已验证的范围,不扩大性能结论。
来源:[[daily/2026-09-18/review.md|试点复盘]]。实际应用见 [[digest/wiki/release.md|发布状态]],角色信息见 [[digest/wiki/people.md|负责人]]。
体验任务:将 front matter 中 `communication` 改为 `writing`,保存后在标签面板检查新标签。修改只属于当前浏览器副本。

View file

@ -0,0 +1,15 @@
---
name: 工作习惯与资料边界
description: 保留用户可理解和可修改的长期习惯。
memory_tags: ["aurora", "personal", "local_first"]
---
# 工作习惯
团队习惯先把原始记录写入文件,再总结为长期知识。内部资料默认保留在受控工作区,发送外部模型前需要确认实际部署与提供商设置。
林澄倾向在会议后当天记录结论;周宁在发布前检查恢复能力;何悦用现场反馈修正未验证的假设。
这些习惯来自 [[resources/kickoff.md|启动约定]] 和 [[resources/offline-feedback.md|现场限制]]。它们可以帮助 Agent 理解上下文,但用户应能检查、修改或删除过时的偏好。
相关知识:[[digest/wiki/local-first.md|本地优先]]、[[digest/personal/communication.md|沟通方式]]。

View file

@ -0,0 +1,19 @@
---
name: 保护源文件并恢复派生状态
description: 先保护用户记忆,再修复索引与关联状态。
memory_tags: ["aurora", "local_first", "procedure"]
---
# 恢复流程
出现搜索结果缺失时,先判断源文件是否存在,再检查派生索引;不要为了修复索引删除或重写用户记忆。
1. 保留工作区文件副本,核对路径与文件内容。
2. 确认本地 ReMe 服务使用正确的工作区。
3. 根据具体问题检查摄取、搜索索引与 wikilink 图谱。
4. `reindex` 从已摄取 chunk 重建搜索和标签索引;它不扫描工作区或重建图谱。新增或改动文件还需正常摄取流程。
5. 用带来源的查询验证恢复结果。
此流程来自 [[daily/2026-09-17/validation.md|验证记录]],遵循 [[digest/wiki/local-first.md|文件作为事实来源]] 的原则。
浏览器演示没有后端索引。其图谱与标签直接从当前示例文件计算;「重置示例」只恢复演示副本,不是生产恢复工具。

View file

@ -0,0 +1,19 @@
---
name: 可复用的发布检查清单
description: 将验收经验写成有证据的操作步骤。
memory_tags: ["aurora", "release", "procedure"]
---
# 发布检查清单
适用于 Aurora 内部试点,由 [[digest/wiki/people.md|周宁]] 执行,林澄确认范围。来源:[[daily/2026-09-18/review.md|复盘]]。
- [ ] 保留工作区文件副本,确认 Markdown 可直接读取。
- [ ] 检查 [[digest/wiki/local-first.md|架构结论]] 的来源链接。
- [ ] 用 `local_first` 和 `release` 标签检查跨目录检索。
- [ ] 停止服务后验证已有文件仍可离线打开。
- [ ] 根据 [[digest/procedure/recovery.md|恢复流程]] 检查派生数据可恢复。
- [ ] 将通过项、失败项和未做项写入验收记录。
- [ ] 更新 [[digest/wiki/release.md|发布状态]],附原始结果。
清单描述可复用经验,不表示勾选动作已触发后台任务。最新原始结果见 [[resources/pilot-results.md|验收记录]]。

View file

@ -0,0 +1,21 @@
---
name: Aurora 项目概览
description: 汇总当前目标、决策、负责人和发布进展。
memory_tags: ["aurora", "planning", "memory"]
---
# Aurora
Aurora 是五人团队的知识助手试点。目标是让成员找回项目背景、保留决策原因,并复用成功的操作经验。
## 当前约定
- 持久数据采用用户拥有的 Markdown 文件,见 [[digest/wiki/local-first.md|本地优先决策]]。
- 首期不包含多设备同步,见 [[digest/wiki/sync.md|范围边界]]。
- 用 [[digest/wiki/memory-tags.md|memory tags]] 聚合主题,用 wikilink 明确文件关系。
## 负责人和状态
[[digest/wiki/people.md|林澄、周宁与何悦]] 分别负责产品、工程和现场试点。9 月 18 日进入内部试用,验收范围见 [[digest/wiki/release.md|发布状态]]。
这篇概览汇总稳定知识;具体事件留在 daily,原始记录留在 resources。来源:[[resources/kickoff.md|启动会议]]。

View file

@ -0,0 +1,19 @@
---
name: 本地优先架构决策
description: 记录当前结论、变更原因与可追溯来源。
memory_tags: ["aurora", "local_first", "decision"]
---
# 本地优先
**当前结论:工作区 Markdown 是持久来源;索引、目录和图谱是可重建的派生数据。**
## 为什么改变方案
[[resources/requirements.md|9 月 14 日草案]] 提出云端集中存储。[[resources/offline-feedback.md|9 月 15 日现场反馈]] 发现弱网与资料外发限制,[[daily/2026-09-15/decision.md|当天决策]] 因此确认本地优先。
## 对用户意味着什么
停止服务不影响直接读取文件。索引损坏时应从源文件恢复,流程见 [[digest/procedure/recovery.md|恢复指南]]。联网模型不可用时,模型回答可能不可用;这不改变文件所有权。
同步单独规划,见 [[digest/wiki/sync.md|同步范围]]。此结论来源于明确证据,不应把早期草案误当成当前方案。

View file

@ -0,0 +1,26 @@
---
name: 用 memory tags 找到同一主题
description: 演示真实标签字段、规范化、文件计数和组合筛选。
memory_tags: ["aurora", "memory", "guide"]
---
# Memory tags
标签保存在 Markdown front matter 的 `memory_tags` 列表中。ReMe 的标签索引来自文件,可以重新构建。
```yaml
memory_tags: [aurora, local_first, decision]
```
## 标签和目录、链接的区别
目录表达文件用途;`local_first` 标签跨 resources、daily 和 digest 聚合主题;[[digest/wiki/local-first.md|wikilink]] 表达具体来源或关系。
## 试一试
1. 在顶部搜索面板选择 `local_first`,观察各目录的命中与标签文件数。
2. 同时选择 `release`:多标签为「任意一个匹配」,与真实 search Job 的 tags 筛选一致。
3. 输入「离线」:关键词与标签共同约束结果。
4. 编辑 [[digest/personal/communication.md|沟通偏好]] 的标签并保存,计数立即变化。
演示支持行内列表和缩进列表。空格转为下划线、大小写统一、重复项合并,每篇默认最多三个有效标签。这里运行关键词筛选;真实 ReMe 的混合向量与 BM25 检索需要服务。

View file

@ -0,0 +1,17 @@
---
name: 项目角色与负责人
description: 让任务和决策有明确的负责人。
memory_tags: ["aurora", "planning", "memory"]
---
# 负责人
| 成员 | 角色 | 责任 |
| ---- | ---- | -------------------- |
| 林澄 | 产品 | 需求、范围与验收 |
| 周宁 | 工程 | 文件操作、索引与恢复 |
| 何悦 | 试点 | 现场验证、反馈收集 |
来源是 [[resources/kickoff.md|启动会议]]。当前项目背景见 [[digest/wiki/aurora.md|概览]],待执行检查见 [[digest/procedure/release-checklist.md|发布流程]]。
林澄偏好简短且附证据的进展汇报,见 [[digest/personal/communication.md|沟通偏好]]。角色知识适合长期保留;每天的具体行动留在日记中。

View file

@ -0,0 +1,19 @@
---
name: 试点发布状态
description: 维护最新状态,并保留结果来源与未完成项。
memory_tags: ["aurora", "release", "memory"]
---
# 发布状态
截至 2026-09-18,Aurora 获准进入五人内部试用。依据为 [[resources/pilot-results.md|验收结果]] 和 [[daily/2026-09-18/review.md|复盘记录]]。
## 已验证
离线读取、标签跨目录筛选、结论来源追溯,以及从文件恢复派生数据。每次发布继续执行 [[digest/procedure/release-checklist.md|检查清单]]。
## 未验证
多设备同步、大规模性能和长期使用效果。何悦将在下一周收集反馈;周宁处理文件和索引问题;林澄决定下一阶段验收范围。
汇报格式遵循 [[digest/personal/communication.md|沟通偏好]]:先结论、后证据,明确区分通过项和未做项。

View file

@ -0,0 +1,19 @@
---
name: 同步范围与边界
description: 把延期事项与当前支持的能力区分清楚。
memory_tags: ["aurora", "local_first", "planning"]
---
# 同步范围
首期 Aurora 只承诺单机工作区,不包含自动多设备同步。此边界由 [[daily/2026-09-15/decision.md|本地优先决策]] 确认。
## 后续需要解决
跨设备保存冲突、重复记录合并、敏感资料的同步范围,以及用户如何恢复旧版本。当前试点结果未验证这些问题。
## 现阶段操作
用户可以下载 Markdown 副本,但手工导出不等于同步机制。浏览器演示中的保存仅属于当前浏览器,也不能当作真实工作区备份。
发布条件见 [[digest/wiki/release.md|发布状态]]。数据所有权原则见 [[digest/wiki/local-first.md|架构知识]]。

View file

@ -0,0 +1,25 @@
---
name: 启动会议记录
description: 角色分工、试点范围与待确认问题。
memory_tags: ["aurora", "planning", "resource"]
---
# 启动会议 · 2026-09-14
参与者:林澄(产品)、周宁(工程)、何悦(试点)。林澄负责需求与验收,周宁负责文件与索引,何悦收集真实使用反馈。
## 已确认
首期使用 Markdown 工作区,包含 resources、daily 和 digest。团队需要三个不同视角:按时间找事件、按主题找结论、按流程复用经验。
## 待确认
何悦将在两处现场检查网络条件。周宁验证文件能否在服务停止时继续读取;林澄准备十个带原始出处的问题,检验回答可追溯性。
## 下一步
- 9 月 15 日回收现场反馈后确认部署方式。
- 9 月 17 日验证离线读取、标签筛选和文件恢复。
- 9 月 18 日根据结果决定是否进入下一阶段。
相关资料:[[resources/requirements.md|需求草案]]、[[digest/wiki/people.md|负责人]]、[[daily/2026-09-14/kickoff.md|当天记录]]。

View file

@ -0,0 +1,19 @@
---
name: 现场离线反馈
description: 弱网现场改变了部署假设。
memory_tags: ["aurora", "local_first", "resource"]
---
# 现场反馈 · 2026-09-15
何悦访问了两处试点现场。A 现场会议室网络中断约 20 分钟;B 现场的内部资料不允许发送到外部服务。两处都需要在断网时读取已有记录。
## 观察与约束
联网模型仍可用于需要推理的任务,但基本文件阅读、编辑和已有记忆的检索不能把公网连接当作前提。用户应能直接检查自己保存了哪些资料。
## 建议
先以本地工作区文件作为持久来源,索引作为可重建数据。跨设备同步作为后续显式开启的能力,不阻塞本次试点。
此建议在 [[daily/2026-09-15/decision.md|决策日记]] 中获确认,并整合到 [[digest/wiki/local-first.md|长期架构知识]]。它修正了 [[resources/requirements.md|草案中的云端假设]]。

View file

@ -0,0 +1,21 @@
---
name: 试点验收结果
description: 记录成功项、剩余问题和证据范围。
memory_tags: ["aurora", "release", "resource"]
---
# 验收结果 · 2026-09-18
以下是虚构 Aurora 试点的示例结果,不是 ReMe 的性能评测。
| 检查 | 结果 | 证据 |
| ----------------- | ---- | ---------------------------------- |
| 离线读取 Markdown | 通过 | 停止服务后仍可用文本编辑器打开 |
| 标签跨目录筛选 | 通过 | `local_first` 找到反馈、决策与流程 |
| 关联来源追溯 | 通过 | 架构结论链接回现场反馈 |
| 索引恢复 | 通过 | 从已有文件恢复派生数据 |
| 多设备同步 | 未做 | 不在首期范围 |
林澄批准五人内部试用;何悦负责收集一周反馈。跨设备冲突和联网模型不可用时的交互提示仍是后续事项。
结论已写入 [[digest/wiki/release.md|发布状态]]。操作步骤见 [[digest/procedure/release-checklist.md|发布检查清单]],过程见 [[daily/2026-09-18/review.md|复盘日记]]。

View file

@ -0,0 +1,22 @@
---
name: Aurora 需求草案
description: 保留早期云端方案,供后续决策追溯。
memory_tags: ["aurora", "planning", "resource"]
---
# 需求草案 · 2026-09-14
Aurora 面向五人产品团队,帮助成员找回项目背景、会议结论与操作经验。首期只支持 Markdown,文件由用户拥有,并应能使用普通编辑器读取。
## 初始方案
最初提出集中式云端数据库:所有会议资料上传后统一生成知识,客户端必须联网查询。这是讨论草案,尚未确认;现场使用条件仍需验证。
## 验收目标
- 每个重要结论都能回到原始记录。
- 同一主题跨资料、日记和长期知识可以被发现。
- 用户能导出完整记忆,不被专用数据库格式锁定。
- 后续决定必须保留原计划与变更理由。
[[resources/kickoff.md|启动会议]] 明确了负责人。现场反馈导致方案调整,当前决策见 [[digest/wiki/local-first.md|本地优先]]。这份草案保留历史,不代表最新架构。

View file

@ -0,0 +1,176 @@
.bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 16px;
padding: 10px 18px;
border-bottom: 1px solid var(--line);
background: var(--subtle);
flex: none;
}
.description {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
align-items: center;
font-size: 12px;
}
.description strong {
color: var(--accent-strong);
}
.description span {
color: var(--muted);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.bar button,
.dialog header button,
.tagHeading button {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 9px;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--ink);
background: var(--surface);
cursor: pointer;
font-size: 12px;
}
.dialog {
width: min(800px, calc(100vw - 32px));
max-height: 85vh;
padding: 24px;
border: 1px solid var(--line);
border-radius: 14px;
color: var(--ink);
background: var(--surface);
box-shadow: 0 20px 80px var(--shadow-strong);
}
.dialog::backdrop {
background: var(--overlay);
}
.dialog header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.dialog h2 {
margin: 0;
font-size: 19px;
}
.dialog header p {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.search {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
}
.search input {
width: 100%;
border: 0;
background: transparent;
color: var(--ink);
font-size: 14px;
outline: none;
}
.search:focus-within {
outline: 2px solid var(--accent);
}
.tagHeading {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin: 18px 0 10px;
font-size: 12px;
}
.tagHeading span {
color: var(--muted);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
display: inline-flex;
gap: 6px;
align-items: center;
border: 1px solid var(--line);
border-radius: 20px;
padding: 5px 10px;
background: var(--subtle);
color: var(--ink);
font: 11px var(--mono);
cursor: pointer;
}
.tag[aria-pressed="true"],
.tag:hover {
border-color: var(--accent);
background: var(--soft);
color: var(--accent-strong);
}
.tag span {
color: var(--muted);
}
.count {
margin: 16px 0 10px;
color: var(--muted);
font-size: 12px;
}
.results {
display: grid;
gap: 8px;
}
.result {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--surface);
color: var(--ink);
text-align: start;
cursor: pointer;
}
.result:hover {
border-color: var(--accent);
background: var(--hover);
}
.result code {
font-size: 11px;
color: var(--muted);
overflow-wrap: anywhere;
}
.result p {
margin: 3px 0;
font-size: 13px;
line-height: 1.6;
}
.result > span {
font: 11px var(--mono);
color: var(--accent-strong);
}
@media (max-width: 640px) {
.bar {
padding: 8px 10px;
}
.dialog {
padding: 16px;
}
}

View file

@ -0,0 +1,62 @@
export const scenarios = {
zh: [
{
question: "Aurora 为什么从云端方案改为本地优先?",
tag: "local_first",
paths: [
"digest/wiki/local-first.md",
"resources/offline-feedback.md",
"daily/2026-09-15/decision.md",
],
answer:
"**以下是基于初始示例文件的预设回答,未运行模型。**\n\nAurora 最初考虑云端集中存储。现场反馈发现断网和内部资料外发限制,因此 9 月 15 日确认本地优先:Markdown 是持久来源,索引和图谱可重建。\n\n来源:[[resources/offline-feedback.md|现场反馈]]、[[daily/2026-09-15/decision.md|决策记录]]、[[digest/wiki/local-first.md|长期架构知识]]。\n\n模型推理仍取决于配置的提供商,跨设备同步不在首期范围。展开上面的工具块,可以检查当前浏览器中的文件内容。",
},
{
question: "用 memory tags 查找发布相关的记忆。",
tag: "release",
paths: [
"digest/wiki/release.md",
"digest/procedure/release-checklist.md",
],
answer:
"**这是预设对话演示。**\n\n`release` 标签把验收资料、日记、发布状态和操作清单聚在一起。标签来自文件的 `memory_tags`,因此不受目录边界限制。\n\n[[digest/wiki/release.md|发布状态]] 记录当前结论;[[digest/procedure/release-checklist.md|检查清单]] 保存可复用经验;[[resources/pilot-results.md|原始结果]] 提供证据。\n\n试着打开「搜索 / 标签」,选择 `release`,再编辑某篇文件的标签并保存。计数与匹配结果会更新。",
},
{
question: "林澄喜欢怎样的项目汇报?",
tag: "personal",
paths: ["digest/personal/communication.md", "daily/2026-09-18/review.md"],
answer:
"**这是基于初始示例的预设回答。**\n\n林澄希望先结论、后证据,尽量三段以内;行动项带负责人和截止日期,未验证项明确标注。\n\n这条偏好来自 [[daily/2026-09-18/review.md|试点复盘]],保存于 [[digest/personal/communication.md|个人记忆]]。用户可以直接检查和修改它。\n\n演示中的工具结果读取当前文件,预设回答不会随编辑自动重新生成。",
},
],
en: [
{
question: "Why did Aurora switch from cloud-first to local-first?",
tag: "local_first",
paths: [
"digest/wiki/local-first.md",
"resources/offline-feedback.md",
"daily/2026-09-15/decision.md",
],
answer:
"**Scripted answer based on the initial example files. No model is running.**\n\nAurora initially considered central cloud storage. Field feedback revealed connectivity failures and restrictions on external sharing. The September 15 decision made Markdown the durable source and indexes and graphs rebuildable.\n\nSources: [[resources/offline-feedback.md|field feedback]], [[daily/2026-09-15/decision.md|the decision]], and [[digest/wiki/local-first.md|durable architecture knowledge]].\n\nModel reasoning still depends on the configured provider; multi-device sync is outside the pilot. Expand the tool blocks to inspect the current browser files.",
},
{
question: "Find release memories using memory tags.",
tag: "release",
paths: [
"digest/wiki/release.md",
"digest/procedure/release-checklist.md",
],
answer:
"**This is a scripted conversation.**\n\nThe `release` tag connects acceptance resources, daily notes, release status, and the operating checklist. It comes from each file's `memory_tags`, so it crosses directory boundaries.\n\n[[digest/wiki/release.md|Release status]] captures the conclusion, [[digest/procedure/release-checklist.md|the checklist]] preserves reusable experience, and [[resources/pilot-results.md|original results]] supply evidence.\n\nOpen Search / Tags, select `release`, then edit a file's tags and save. Counts and matches update.",
},
{
question: "How does Lin prefer project updates?",
tag: "personal",
paths: ["digest/personal/communication.md", "daily/2026-09-18/review.md"],
answer:
"**Scripted answer based on the initial examples.**\n\nLin prefers conclusions before evidence, usually within three paragraphs. Actions need owners and deadlines; unverified claims must be marked.\n\nThe preference comes from [[daily/2026-09-18/review.md|the pilot review]] and lives in [[digest/personal/communication.md|personal memory]], where users can inspect and edit it.\n\nTool results read the current files. This scripted answer does not regenerate when you edit them.",
},
],
};

View file

@ -0,0 +1,213 @@
import { parseDocument } from "yaml";
import type { FileStat, GraphSnapshot } from "../app/types.ts";
import { parseMarkdownFrontmatter } from "../app/files-workspace/markdown.ts";
export const DEMO_STORAGE_KEY = "reme-demo-files-v1";
export const WELCOME_PATH = "START_HERE.md";
const INITIAL_MTIME = "2026-09-18T09:00:00.000Z";
type SavedFile = { content: string; mtime: string };
type StoragePort = Pick<Storage, "getItem" | "setItem" | "removeItem">;
/** Tags are derived from YAML source files, with ReMe's default list/count limits. */
export function memoryTags(content: string): string[] {
const block = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content)?.[1];
if (!block) return [];
let value: unknown;
try {
const document = parseDocument(block);
if (document.errors.length) return [];
value = document.toJS({ maxAliasCount: 50 })?.memory_tags;
} catch {
return [];
}
if (!Array.isArray(value)) return [];
const tags = value.flatMap((item: unknown) => {
if (
typeof item !== "string" &&
!(typeof item === "number" && Number.isInteger(item))
)
return [];
const raw = String(item).trim().replace(/\s+/g, "_");
if (!raw || [...raw].length > 64 || !/[\p{L}\p{N}]/u.test(raw)) return [];
return [raw.toLowerCase()];
});
return [...new Set(tags)].slice(0, 3);
}
export function wikilinks(content: string) {
return [
...content.matchAll(
/!?\[\[([^[\]|#\n]+?)(?:#([^[\]|\n]+))?(?:\|[^[\]\n]+)?\]\]/g,
),
].map((match) => ({
target: match[1].trim(),
anchor: match[2]?.trim() || null,
}));
}
export function createDemoWorkspace(
seeds: Record<string, string>,
storage?: StoragePort,
key = DEMO_STORAGE_KEY,
) {
function savedFiles(): Record<string, SavedFile> {
try {
const saved: unknown = JSON.parse(storage?.getItem(key) || "{}");
if (!saved || typeof saved !== "object" || Array.isArray(saved))
return {};
return Object.fromEntries(
Object.entries(saved).filter(
([path, file]) =>
Object.hasOwn(seeds, path) &&
file &&
typeof file.content === "string" &&
typeof file.mtime === "string" &&
Number.isFinite(Date.parse(file.mtime)),
),
);
} catch {
return {};
}
}
let changes = savedFiles();
const files = () => {
if (storage) changes = savedFiles();
return Object.fromEntries(
Object.entries(seeds).map(([path, content]) => [
path,
changes[path]?.content ?? content,
]),
);
};
const stat = (path: string, content: string): FileStat => ({
path,
exists: true,
type: "file",
mtime: changes[path]?.mtime || INITIAL_MTIME,
size: new TextEncoder().encode(content).length,
});
const read = (path: string) => {
if (!Object.hasOwn(seeds, path)) throw new Error(`File not found: ${path}`);
const content = files()[path];
return { content, stat: stat(path, content) };
};
const search = (query = "", tags: string[] = []) => {
const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
return Object.entries(files()).flatMap(([path, content]) => {
const parsed = parseMarkdownFrontmatter(content);
const fileTags = memoryTags(content);
const title =
parsed.entries.find((entry) => entry.key === "name")?.value || path;
const haystack = `${path}\n${content}\n${fileTags.join(
" ",
)}`.toLowerCase();
if (
(tags.length && !tags.some((tag) => fileTags.includes(tag))) ||
!terms.every((term) => haystack.includes(term))
)
return [];
const lines = parsed.body
.split(/\r?\n/)
.filter((line) => line.trim() && !line.startsWith("#"));
const snippet =
lines.find(
(line) =>
terms.length &&
terms.some((term) => line.toLowerCase().includes(term)),
) ||
lines[0] ||
"";
return [{ path, title, tags: fileTags, snippet: snippet.slice(0, 180) }];
});
};
return {
files,
read,
search,
save(path: string, content: string, expectedMtime?: string) {
const previous = read(path).stat.mtime!;
if (expectedMtime && expectedMtime !== previous)
throw new Error(
"This file changed in another tab. Reopen it before saving. / 文件已在其他标签页修改,请重新打开后保存。",
);
if (new TextEncoder().encode(content).length > 512_000)
throw new Error("Demo files must be smaller than 512 KB.");
const mtime = new Date(
Math.max(Date.now(), Date.parse(previous) + 1),
).toISOString();
const next = { ...changes, [path]: { content, mtime } };
storage?.setItem(key, JSON.stringify(next));
changes = next;
return stat(path, content);
},
reset() {
storage?.removeItem(key);
changes = {};
},
tags() {
const counts = new Map<string, number>();
search().forEach((file) =>
file.tags.forEach((tag) => counts.set(tag, (counts.get(tag) || 0) + 1)),
);
return [...counts]
.sort(([a], [b]) => a.localeCompare(b))
.map(([tag, count]) => ({ tag, count }));
},
graph(): GraphSnapshot {
const entries = Object.entries(files());
const nodes: GraphSnapshot["nodes"] = entries.map(([path, content]) => {
const { entries: metadata } = parseMarkdownFrontmatter(content);
return {
id: path,
path,
name:
metadata.find((entry) => entry.key === "name")?.value ||
path.split("/").pop()!,
description:
metadata.find((entry) => entry.key === "description")?.value || "",
indexed: true,
virtual: false,
};
});
const edges: GraphSnapshot["edges"] = [];
const known = new Set(nodes.map((node) => node.id));
const seen = new Set<string>();
for (const [source, content] of entries)
for (const { target, anchor } of wikilinks(content)) {
const key = `${source}\0${target}\0${anchor}`;
if (!target || seen.has(key)) continue;
seen.add(key);
if (!known.has(target)) {
known.add(target);
nodes.push({
id: target,
path: target,
name: target,
description: "",
indexed: false,
virtual: false,
});
}
edges.push({ source, target, target_anchor: anchor });
}
for (const root of ["wiki", "personal", "procedure"]) {
const id = `virtual:${root}`;
nodes.push({
id,
path: "",
name: root,
description: "",
indexed: false,
virtual: true,
});
entries
.filter(([path]) => path.startsWith(`digest/${root}/`))
.forEach(([path]) =>
edges.push({ source: id, target: path, target_anchor: null }),
);
}
return { version: 1, nodes, edges };
},
};
}

View file

@ -7,7 +7,7 @@
name="description"
content="Browse, edit, and discuss your local-first ReMe memory workspace."
/>
<link rel="icon" href="/reme-icon.svg" />
<link rel="icon" href="./reme-icon.svg" />
<title>ReMe Workspace</title>
<script>
(function () {

View file

@ -42,6 +42,7 @@
"vinext": "1.0.0-beta.2",
"vite": "8.2.1",
"wrangler": "4.121.0",
"yaml": "2.9.0",
"zustand": "^5.0.14"
},
"engines": {
@ -11906,6 +11907,22 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -28,19 +28,21 @@
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"build:static": "vite build --config vite.static.config.ts",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
"test": "npm run build && npm run build:static && node --test tests/*.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern dist-static --ignore-pattern .next",
"test": "npm run build && npm run build:static && npm run build:demo && node --test tests/*.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern dist-static --ignore-pattern dist-demo --ignore-pattern .next",
"format": "tsc --noEmit && prettier --write .",
"format:check": "tsc --noEmit && prettier --check .",
"db:generate": "drizzle-kit generate",
"prepack": "npm run build:static"
"prepack": "npm run build:static",
"build:demo": "vite build --config vite.static.config.ts --mode demo",
"dev:demo": "vite --config vite.static.config.ts --mode demo"
},
"devDependencies": {
"@agentscope-ai/icons": "^1.0.67",
"@monaco-editor/react": "^4.7.0",
"@cloudflare/vite-plugin": "1.51.3",
"@cloudflare/workers-types": "^5.20260804.1",
"@eslint/js": "9.39.4",
"@monaco-editor/react": "^4.7.0",
"@next/eslint-plugin-next": "16.2.6",
"@tailwindcss/postcss": "4.2.1",
"@types/node": "22.19.19",
@ -48,8 +50,8 @@
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"drizzle-orm": "0.45.2",
"drizzle-kit": "0.31.10",
"drizzle-orm": "0.45.2",
"eslint": "9.39.4",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
@ -69,6 +71,7 @@
"vinext": "1.0.0-beta.2",
"vite": "8.2.1",
"wrangler": "4.121.0",
"yaml": "2.9.0",
"zustand": "^5.0.14"
},
"overrides": {

View file

@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { access, readFile, readdir } from "node:fs/promises";
import test from "node:test";
test("the browser demo builds for a subdirectory with no service API or live settings", async () => {
const directory = new URL("../dist-demo/", import.meta.url);
const html = await readFile(new URL("index.html", directory), "utf8");
assert.match(html, /<title>Try ReMe Studio<\/title>/);
assert.match(html, /src="\.\/assets\//);
for (const match of html.matchAll(/(?:src|href)="(\.\/[^"?#]+)"/g))
await access(new URL(match[1], directory));
const assets = await readdir(new URL("assets/", directory));
assert.ok(
!assets.some(
(name) => name.startsWith("settings-center") || name.endsWith(".map"),
),
);
const scripts = (
await Promise.all(
assets
.filter((name) => name.endsWith(".js"))
.map((name) => readFile(new URL(`assets/${name}`, directory), "utf8")),
)
).join("\n");
assert.doesNotMatch(scripts, /127\.0\.0\.1:2333|expected_mtime|health_check/);
});

View file

@ -0,0 +1,217 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import {
createDemoWorkspace,
memoryTags,
wikilinks,
DEMO_STORAGE_KEY,
} from "../demo/workspace.ts";
import { scenarios } from "../demo/scenarios.ts";
function storage() {
const values = new Map();
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
removeItem: (key) => values.delete(key),
};
}
const note = (tags, body = "Offline release") =>
`---\nname: Example\nmemory_tags: ${tags}\n---\n${body}`;
const seeds = {
"digest/wiki/a.md": note(
'["aurora", "local_first"]',
"Offline plan [[digest/wiki/b.md#Scope|Release]]",
),
"digest/wiki/b.md": note('["release"]'),
};
function content(language) {
const directory = new URL(`../demo/content/${language}/`, import.meta.url);
return Object.fromEntries(
fs
.readdirSync(directory, { recursive: true })
.filter((file) => file.endsWith(".md"))
.map((file) => [
file.split(path.sep).join("/"),
fs.readFileSync(new URL(file, directory), "utf8"),
]),
);
}
test("memory_tags respects strict lists, normalization, deduplication, and the default three-tag limit", () => {
assert.deepEqual(
memoryTags(
note(
'[true, 1.5, {}, [], " Local First ", "LOCAL FIRST", "中文", 42, "extra"]',
),
),
["local_first", "中文", "42"],
);
assert.deepEqual(memoryTags(note("[aurora, 'Release', personal]")), [
"aurora",
"release",
"personal",
]);
assert.deepEqual(memoryTags(note('["tag,with,commas", "release"]')), [
"tag,with,commas",
"release",
]);
assert.deepEqual(
memoryTags(
note(`\n - Local First\n - RELEASE\n - local first\n - personal`),
),
["local_first", "release", "personal"],
);
assert.deepEqual(memoryTags(note("[aurora, release] # shared topic")), [
"aurora",
"release",
]);
assert.deepEqual(memoryTags(note("[broken")), []);
assert.deepEqual(memoryTags(note("aurora")), []);
assert.deepEqual(memoryTags(note(`["${"a".repeat(65)}", "---", false]`)), []);
assert.deepEqual(memoryTags("# Body\nmemory_tags: [aurora]"), []);
});
test("saving updates tags, counts, search and graph from files and survives a reload", () => {
const saved = storage();
const workspace = createDemoWorkspace(seeds, saved);
const initial = workspace.read("digest/wiki/a.md");
assert.equal(workspace.search("offline", ["local_first"]).length, 1);
workspace.save(
"digest/wiki/a.md",
note('["writing"]', "Updated [[missing.md]]"),
initial.stat.mtime,
);
const restored = createDemoWorkspace(seeds, saved);
assert.equal(restored.search("offline", ["local_first"]).length, 0);
assert.equal(
restored.search("updated", ["writing"])[0].path,
"digest/wiki/a.md",
);
assert.deepEqual(restored.tags(), [
{ tag: "release", count: 1 },
{ tag: "writing", count: 1 },
]);
assert.ok(
restored.graph().edges.some((edge) => edge.target === "missing.md"),
);
assert.equal(
restored.graph().nodes.find((node) => node.id === "missing.md").indexed,
false,
);
assert.ok(
!restored
.graph()
.edges.some(
(edge) =>
edge.source === "digest/wiki/a.md" &&
edge.target === "digest/wiki/b.md",
),
);
assert.match(seeds["digest/wiki/a.md"], /local_first/);
restored.reset();
assert.equal(restored.read("digest/wiki/a.md").content, initial.content);
});
test("tag filters match any selected tag, while keywords constrain every result", () => {
const workspace = createDemoWorkspace(seeds);
assert.equal(workspace.search("", ["local_first", "release"]).length, 2);
assert.equal(
workspace.search("offline plan", ["local_first", "release"]).length,
1,
);
assert.equal(workspace.search("", ["nonexistent"]).length, 0);
const normalized = createDemoWorkspace({
"note.md": note('["Local First"]'),
});
assert.equal(normalized.search("local_first", ["local_first"]).length, 1);
});
test("stale browser writes and unknown paths are rejected without losing the newer file", () => {
const saved = storage();
const one = createDemoWorkspace(seeds, saved);
const two = createDemoWorkspace(seeds, saved);
const initial = one.read("digest/wiki/a.md");
two.save("digest/wiki/a.md", "Newer content", initial.stat.mtime);
assert.throws(
() => one.save("digest/wiki/a.md", "Stale content", initial.stat.mtime),
/changed/,
);
assert.equal(one.read("digest/wiki/a.md").content, "Newer content");
for (const file of ["../escape.md", "toString", "__proto__", "new.md"]) {
assert.throws(() => one.read(file), /not found/);
assert.throws(() => one.save(file, "content"), /not found/);
}
});
test("failed persistence does not report success or replace the source", () => {
const workspace = createDemoWorkspace(seeds, {
getItem: () => null,
setItem: () => {
throw new Error("Quota exceeded");
},
removeItem: () => {},
});
assert.throws(() => workspace.save("digest/wiki/a.md", "not saved"), /Quota/);
assert.equal(
workspace.read("digest/wiki/a.md").content,
seeds["digest/wiki/a.md"],
);
});
test("corrupt persisted content falls back to examples, and languages stay isolated", () => {
const saved = storage();
saved.setItem(DEMO_STORAGE_KEY, "broken JSON");
assert.equal(
createDemoWorkspace(seeds, saved).read("digest/wiki/a.md").content,
seeds["digest/wiki/a.md"],
);
saved.setItem(
DEMO_STORAGE_KEY,
JSON.stringify({
"digest/wiki/a.md": { content: "bad", mtime: "invalid" },
}),
);
assert.equal(
createDemoWorkspace(seeds, saved).read("digest/wiki/a.md").content,
seeds["digest/wiki/a.md"],
);
createDemoWorkspace(seeds, saved, "zh").save("digest/wiki/a.md", "中文修改");
assert.notEqual(
createDemoWorkspace(seeds, saved, "en").read("digest/wiki/a.md").content,
"中文修改",
);
});
for (const language of ["zh", "en"])
test(`${language} examples form a complete source-to-memory chain with valid tags and scripted citations`, () => {
const files = content(language);
assert.equal(Object.keys(files).length, 21);
assert.deepEqual(
Object.keys(files).sort(),
Object.keys(content(language === "zh" ? "en" : "zh")).sort(),
);
for (const [file, text] of Object.entries(files)) {
assert.ok(text.length > 150, `${file}: complete content`);
assert.equal(memoryTags(text).length, 3, `${file}: three valid tags`);
for (const { target } of wikilinks(text))
assert.ok(
Object.hasOwn(files, target),
`${file}: missing target ${target}`,
);
}
for (const scenario of scenarios[language]) {
assert.ok(createDemoWorkspace(files).search("", [scenario.tag]).length);
for (const file of scenario.paths) assert.ok(Object.hasOwn(files, file));
for (const { target } of wikilinks(scenario.answer))
assert.ok(Object.hasOwn(files, target));
}
const graph = createDemoWorkspace(files).graph();
for (const category of ["wiki", "personal", "procedure"])
assert.ok(
graph.edges.some((edge) => edge.source === `virtual:${category}`),
);
});

View file

@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import test from "node:test";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown from "react-markdown";
import {
remarkWikilinks,
workspaceLinkTarget,
} from "../app/files-workspace/wikilinks.ts";
test("workspace link fragments tolerate malformed Markdown URLs", () => {
assert.equal(workspaceLinkTarget("#reme-file=digest%2Fa.md"), "digest/a.md");
assert.equal(workspaceLinkTarget("#reme-file=%E4%B8%AD.md"), "中.md");
for (const href of [
undefined,
"https://example.com",
"#reme-file=",
"#reme-file=%",
"#reme-file=%FF",
])
assert.equal(workspaceLinkTarget(href), undefined);
});
function parse(content, wikilinks = true) {
const processor = unified().use(remarkParse).use(remarkGfm);
if (wikilinks) processor.use(remarkWikilinks);
return processor.runSync(processor.parse(content));
}
function render(content, wikilinks = true) {
return renderToStaticMarkup(
createElement(
ReactMarkdown,
{ remarkPlugins: wikilinks ? [remarkGfm, remarkWikilinks] : [remarkGfm] },
content,
),
);
}
function links(tree) {
return (
tree.children?.flatMap((node) =>
node.type === "link" ? [node] : links(node),
) || []
);
}
test("wikilinks open literal workspace targets and preserve aliases", () => {
const result = links(
parse("See [[digest/a.md#Section|Decision]] and [[b]] today."),
);
assert.deepEqual(
result.map((node) => node.url),
["#reme-file=digest%2Fa.md", "#reme-file=b"],
);
assert.equal(result[0].children[0].value, "Decision");
});
test("Markdown punctuation in targets and aliases stays literal", () => {
for (const [target, alias] of [
["resources/release~draft~.md", "**发布状态**"],
["notes/a*b*.md", "a `code` label"],
["notes/a&copy;.md", "https://example.com"],
["notes/a\\b.md", "~~old~~"],
]) {
const result = links(parse(`Before [[${target}#Scope|${alias}]] after.`));
assert.equal(result.length, 1);
assert.equal(workspaceLinkTarget(result[0].url), target);
assert.deepEqual(result[0].children, [{ type: "text", value: alias }]);
}
});
test("code examples and existing Markdown links are not rewritten", () => {
const content = [
"```md",
"[[sample.md]]",
"```",
"",
"`[[sample.md]]` and [See [[sample.md]]](https://example.com)",
"![See [[sample.md]]](image.png)",
].join("\n");
assert.equal(render(content), render(content, false));
});
test("malformed wikilinks leave normal Markdown parsing unchanged", () => {
for (const content of [
"[[ ]]",
"[[a.md",
"[[a.md] text",
"[[a.md\nb.md]]",
"[[a.md#]]",
"[[a.md|]]",
"[ordinary](https://example.com)",
])
assert.deepEqual(parse(content), parse(content, false));
});
test("wikilinks work beside ordinary emphasis and consecutive links", () => {
const tree = parse("**Before [[a.md|first]]** [[b.md]][[c.md]] *after*");
assert.deepEqual(
links(tree).map((node) => workspaceLinkTarget(node.url)),
["a.md", "b.md", "c.md"],
);
assert.equal(tree.children[0].children[0].type, "strong");
assert.equal(tree.children[0].children.at(-1).type, "emphasis");
});
test("reference links and images preserve literal wikilinks in their labels", () => {
for (const content of [
"[See [[sample.md]]][ref]\n\n[ref]: https://example.com",
"[See [[sample.md]]][REF]\n\n[ref]: https://example.com",
"![See [[sample.md]]][ref]\n\n[ref]: image.png",
]) {
assert.equal(render(content), render(content, false));
assert.doesNotMatch(render(content), /#reme-file=/);
}
});
test("escaped brackets and character entities remain literal text", () => {
for (const content of [
String.raw`\[\[example.md\]\]`,
"&#91;&#91;example.md&#93;&#93;",
"&#x5b;&#x5b;example.md&#x5d;&#x5d;",
String.raw`\[[example.md]]`,
]) {
assert.equal(render(content), render(content, false));
assert.equal(links(parse(content)).length, 0);
}
const mixed = String.raw`\[\[literal.md\]\] [[real.md]] &#91;&#91;entity.md&#93;&#93;`;
assert.deepEqual(
links(parse(mixed)).map((node) => node.url),
["#reme-file=real.md"],
);
assert.match(render(mixed), /\[\[literal\.md\]\]/);
assert.match(render(mixed), /\[\[entity\.md\]\]/);
});

View file

@ -1,32 +1,65 @@
import react from "@vitejs/plugin-react";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, loadEnv } from "vite";
import { defineConfig, loadEnv, type Plugin } from "vite";
const staticDir = fileURLToPath(new URL("./static/", import.meta.url));
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const demo = mode === "demo";
const demoApi = fileURLToPath(new URL("./demo/api.ts", import.meta.url));
const appApi = fileURLToPath(new URL("./app/api", import.meta.url));
const hiddenPanels = new Set(
demo
? [fileURLToPath(new URL("./app/settings-center", import.meta.url))]
: ["DemoBar", "DemoTags"].map((name) =>
fileURLToPath(new URL(`./demo/${name}`, import.meta.url)),
),
);
const browserApi: Plugin = {
name: "reme-demo-api",
enforce: "pre",
transformIndexHtml(html) {
return demo
? html.replace(
"<title>ReMe Workspace</title>",
"<title>Try ReMe Studio</title>",
)
: html;
},
resolveId(id, importer) {
if (!importer) return;
const resolved = path.resolve(path.dirname(importer), id);
if (hiddenPanels.has(resolved)) return "\0reme-hidden-panel";
if (demo && resolved === appApi) return demoApi;
},
load(id) {
if (id === "\0reme-hidden-panel") return "export default () => null";
},
};
return {
plugins: [react()],
base: demo ? "./" : "/",
plugins: [browserApi, react()],
resolve: {
alias: {
"next/dynamic": path.resolve(staticDir, "next-dynamic.tsx"),
},
},
define: {
"process.env.NEXT_PUBLIC_REME_DEMO": JSON.stringify(String(demo)),
"process.env.NEXT_PUBLIC_REME_API_URL": JSON.stringify(
env.VITE_REME_API_URL || "/",
),
"process.env.NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS": JSON.stringify(
env.VITE_REME_WORKSPACE_EXTENSIONS ?? "",
demo ? "md" : env.VITE_REME_WORKSPACE_EXTENSIONS ?? "",
),
},
build: {
outDir: "dist-static",
outDir: demo ? "dist-demo" : "dist-static",
emptyOutDir: true,
sourcemap: mode !== "production",
sourcemap: mode !== "production" && !demo,
},
};
});