mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-03 19:25:19 +02:00
Merge dev into slack2, resolve command-executor shell conflict
Keep EXECUTION_SHELL from dev's OS-aware runtime context approach, remove redundant getShell() function from slack2. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
1094a763dc
58 changed files with 7697 additions and 3528 deletions
|
|
@ -1,5 +1,8 @@
|
||||||
import { skillCatalog } from "./skills/index.js";
|
import { skillCatalog } from "./skills/index.js";
|
||||||
import { WorkDir as BASE_DIR } from "../../config/config.js";
|
import { WorkDir as BASE_DIR } from "../../config/config.js";
|
||||||
|
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
|
||||||
|
|
||||||
|
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||||
|
|
||||||
export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks.
|
export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks.
|
||||||
|
|
||||||
|
|
@ -39,6 +42,8 @@ When a user asks for ANY task that might require external capabilities (web sear
|
||||||
- Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files.
|
- Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files.
|
||||||
- Keep user data safe—double-check before editing or deleting important resources.
|
- Keep user data safe—double-check before editing or deleting important resources.
|
||||||
|
|
||||||
|
${runtimeContextPrompt}
|
||||||
|
|
||||||
## Workspace access & scope
|
## Workspace access & scope
|
||||||
- You have full read/write access inside \`${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually.
|
- You have full read/write access inside \`${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually.
|
||||||
- If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location.
|
- If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location.
|
||||||
|
|
|
||||||
69
apps/cli/src/application/assistant/runtime-context.ts
Normal file
69
apps/cli/src/application/assistant/runtime-context.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh';
|
||||||
|
export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
|
||||||
|
|
||||||
|
export interface RuntimeContext {
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
osName: RuntimeOsName;
|
||||||
|
shellDialect: RuntimeShellDialect;
|
||||||
|
shellExecutable: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
|
||||||
|
return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext {
|
||||||
|
if (platform === 'win32') {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'Windows',
|
||||||
|
shellDialect: 'windows-cmd',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'macOS',
|
||||||
|
shellDialect: 'posix-sh',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'linux') {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'Linux',
|
||||||
|
shellDialect: 'posix-sh',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'Unknown',
|
||||||
|
shellDialect: 'posix-sh',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeContextPrompt(runtime: RuntimeContext): string {
|
||||||
|
if (runtime.shellDialect === 'windows-cmd') {
|
||||||
|
return `## Runtime Platform (CRITICAL)
|
||||||
|
- Detected platform: **${runtime.platform}**
|
||||||
|
- Detected OS: **${runtime.osName}**
|
||||||
|
- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax)
|
||||||
|
- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`).
|
||||||
|
- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`).
|
||||||
|
- Do not assume macOS/Linux command syntax when the runtime is Windows.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `## Runtime Platform (CRITICAL)
|
||||||
|
- Detected platform: **${runtime.platform}**
|
||||||
|
- Detected OS: **${runtime.osName}**
|
||||||
|
- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax)
|
||||||
|
- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`).
|
||||||
|
- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux).
|
||||||
|
- Do not assume Windows command syntax when the runtime is POSIX.`;
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { exec, execSync } from 'child_process';
|
import { exec, execSync } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js';
|
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js';
|
||||||
|
import { getExecutionShell } from '../assistant/runtime-context.js';
|
||||||
|
|
||||||
const execPromise = promisify(exec);
|
const execPromise = promisify(exec);
|
||||||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
|
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
|
||||||
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
||||||
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
||||||
|
const EXECUTION_SHELL = getExecutionShell();
|
||||||
|
|
||||||
function sanitizeToken(token: string): string {
|
function sanitizeToken(token: string): string {
|
||||||
return token.trim().replace(/^['"]+|['"]+$/g, '');
|
return token.trim().replace(/^['"]+|['"]+$/g, '');
|
||||||
|
|
@ -91,7 +93,7 @@ export async function executeCommand(
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
||||||
shell: '/bin/sh', // use sh for cross-platform compatibility
|
shell: EXECUTION_SHELL,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -125,7 +127,7 @@ export function executeCommandSync(
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
shell: '/bin/sh',
|
shell: EXECUTION_SHELL,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
||||||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||||
import { search } from '@x/core/dist/search/search.js';
|
import { search } from '@x/core/dist/search/search.js';
|
||||||
|
import { versionHistory } from '@x/core';
|
||||||
|
import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js';
|
||||||
|
|
||||||
type InvokeChannels = ipc.InvokeChannels;
|
type InvokeChannels = ipc.InvokeChannels;
|
||||||
type IPCChannels = ipc.IPCChannels;
|
type IPCChannels = ipc.IPCChannels;
|
||||||
|
|
@ -110,6 +112,18 @@ let watcher: FSWatcher | null = null;
|
||||||
const changeQueue = new Set<string>();
|
const changeQueue = new Set<string>();
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit knowledge commit event to all renderer windows
|
||||||
|
*/
|
||||||
|
function emitKnowledgeCommitEvent(): void {
|
||||||
|
const windows = BrowserWindow.getAllWindows();
|
||||||
|
for (const win of windows) {
|
||||||
|
if (!win.isDestroyed() && win.webContents) {
|
||||||
|
win.webContents.send('knowledge:didCommit', {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emit workspace change event to all renderer windows
|
* Emit workspace change event to all renderer windows
|
||||||
*/
|
*/
|
||||||
|
|
@ -288,6 +302,9 @@ export function stopServicesWatcher(): void {
|
||||||
* Add new handlers here as you add channels to IPCChannels
|
* Add new handlers here as you add channels to IPCChannels
|
||||||
*/
|
*/
|
||||||
export function setupIpcHandlers() {
|
export function setupIpcHandlers() {
|
||||||
|
// Forward knowledge commit events to renderer for panel refresh
|
||||||
|
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
|
||||||
|
|
||||||
registerIpcHandlers({
|
registerIpcHandlers({
|
||||||
'app:getVersions': async () => {
|
'app:getVersions': async () => {
|
||||||
// args is null for this channel (no request payload)
|
// args is null for this channel (no request payload)
|
||||||
|
|
@ -527,9 +544,27 @@ export function setupIpcHandlers() {
|
||||||
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
||||||
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
||||||
},
|
},
|
||||||
|
// Knowledge version history handlers
|
||||||
|
'knowledge:history': async (_event, args) => {
|
||||||
|
const commits = await versionHistory.getFileHistory(args.path);
|
||||||
|
return { commits };
|
||||||
|
},
|
||||||
|
'knowledge:fileAtCommit': async (_event, args) => {
|
||||||
|
const content = await versionHistory.getFileAtCommit(args.path, args.oid);
|
||||||
|
return { content };
|
||||||
|
},
|
||||||
|
'knowledge:restore': async (_event, args) => {
|
||||||
|
await versionHistory.restoreFile(args.path, args.oid);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
// Search handler
|
// Search handler
|
||||||
'search:query': async (_event, args) => {
|
'search:query': async (_event, args) => {
|
||||||
return search(args.query, args.limit, args.types);
|
return search(args.query, args.limit, args.types);
|
||||||
},
|
},
|
||||||
|
// Inline task schedule classification
|
||||||
|
'inline-task:classifySchedule': async (_event, args) => {
|
||||||
|
const schedule = await classifySchedule(args.instruction);
|
||||||
|
return { schedule };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j
|
||||||
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
|
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
|
||||||
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
|
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
|
||||||
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
|
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
|
||||||
|
import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js";
|
||||||
|
import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
|
||||||
|
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
|
||||||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||||
import started from "electron-squirrel-startup";
|
import started from "electron-squirrel-startup";
|
||||||
|
|
@ -170,6 +173,15 @@ app.whenReady().then(async () => {
|
||||||
// start knowledge graph builder
|
// start knowledge graph builder
|
||||||
initGraphBuilder();
|
initGraphBuilder();
|
||||||
|
|
||||||
|
// start email labeling service
|
||||||
|
initEmailLabeling();
|
||||||
|
|
||||||
|
// start note tagging service
|
||||||
|
initNoteTagging();
|
||||||
|
|
||||||
|
// start inline task service (@rowboat: mentions)
|
||||||
|
initInlineTasks();
|
||||||
|
|
||||||
// start background agent runner (scheduled agents)
|
// start background agent runner (scheduled agents)
|
||||||
initAgentRunner();
|
initAgentRunner();
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
820
apps/x/apps/renderer/src/components/bases-view.tsx
Normal file
820
apps/x/apps/renderer/src/components/bases-view.tsx
Normal file
|
|
@ -0,0 +1,820 @@
|
||||||
|
import * as React from 'react'
|
||||||
|
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||||
|
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save } from 'lucide-react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||||
|
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { splitFrontmatter, extractAllFrontmatterValues } from '@/lib/frontmatter'
|
||||||
|
import { useDebounce } from '@/hooks/use-debounce'
|
||||||
|
|
||||||
|
interface TreeNode {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
kind: 'file' | 'dir'
|
||||||
|
children?: TreeNode[]
|
||||||
|
stat?: { size: number; mtimeMs: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoteEntry = {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
folder: string
|
||||||
|
fields: Record<string, string | string[]>
|
||||||
|
mtimeMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortDir = 'asc' | 'desc'
|
||||||
|
type ActiveFilter = { category: string; value: string }
|
||||||
|
|
||||||
|
export type BaseConfig = {
|
||||||
|
name: string
|
||||||
|
visibleColumns: string[]
|
||||||
|
columnWidths: Record<string, number>
|
||||||
|
sort: { field: string; dir: SortDir }
|
||||||
|
filters: ActiveFilter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_BASE_CONFIG: BaseConfig = {
|
||||||
|
name: 'All Notes',
|
||||||
|
visibleColumns: ['name', 'folder', 'relationship', 'topic', 'status', 'mtimeMs'],
|
||||||
|
columnWidths: {},
|
||||||
|
sort: { field: 'mtimeMs', dir: 'desc' },
|
||||||
|
filters: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25
|
||||||
|
|
||||||
|
/** Built-in columns that don't come from frontmatter */
|
||||||
|
const BUILTIN_COLUMNS = ['name', 'folder', 'mtimeMs'] as const
|
||||||
|
type BuiltinColumn = (typeof BUILTIN_COLUMNS)[number]
|
||||||
|
|
||||||
|
const BUILTIN_LABELS: Record<BuiltinColumn, string> = {
|
||||||
|
name: 'Name',
|
||||||
|
folder: 'Folder',
|
||||||
|
mtimeMs: 'Last Modified',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default pixel widths for columns */
|
||||||
|
const DEFAULT_WIDTHS: Record<string, number> = {
|
||||||
|
name: 200,
|
||||||
|
folder: 140,
|
||||||
|
mtimeMs: 140,
|
||||||
|
}
|
||||||
|
const DEFAULT_FRONTMATTER_WIDTH = 150
|
||||||
|
|
||||||
|
/** Convert key to title case: `first_met` → `First Met` */
|
||||||
|
function toTitleCase(key: string): string {
|
||||||
|
if (key in BUILTIN_LABELS) return BUILTIN_LABELS[key as BuiltinColumn]
|
||||||
|
return key
|
||||||
|
.split('_')
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
type BasesViewProps = {
|
||||||
|
tree: TreeNode[]
|
||||||
|
onSelectNote: (path: string) => void
|
||||||
|
config: BaseConfig
|
||||||
|
onConfigChange: (config: BaseConfig) => void
|
||||||
|
isDefaultBase: boolean
|
||||||
|
onSave: (name: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
|
||||||
|
return nodes.flatMap((n) =>
|
||||||
|
n.kind === 'file' && n.name.endsWith('.md')
|
||||||
|
? [{ path: n.path, name: n.name.replace(/\.md$/i, ''), mtimeMs: n.stat?.mtimeMs ?? 0 }]
|
||||||
|
: n.children
|
||||||
|
? collectFiles(n.children)
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFolder(path: string): string {
|
||||||
|
const parts = path.split('/')
|
||||||
|
if (parts.length >= 3) return parts[1]
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ms: number): string {
|
||||||
|
if (!ms) return ''
|
||||||
|
const d = new Date(ms)
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtersEqual(a: ActiveFilter, b: ActiveFilter): boolean {
|
||||||
|
return a.category === b.category && a.value === b.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFilter(filters: ActiveFilter[], f: ActiveFilter): boolean {
|
||||||
|
return filters.some((x) => filtersEqual(x, f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the string values for a column from a note */
|
||||||
|
function getColumnValues(note: NoteEntry, column: string): string[] {
|
||||||
|
if (column === 'name') return [note.name]
|
||||||
|
if (column === 'folder') return [note.folder]
|
||||||
|
if (column === 'mtimeMs') return []
|
||||||
|
const v = note.fields[column]
|
||||||
|
if (!v) return []
|
||||||
|
return Array.isArray(v) ? v : [v]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a single sortable string for a column */
|
||||||
|
function getSortValue(note: NoteEntry, column: string): string | number {
|
||||||
|
if (column === 'name') return note.name
|
||||||
|
if (column === 'folder') return note.folder
|
||||||
|
if (column === 'mtimeMs') return note.mtimeMs
|
||||||
|
const v = note.fields[column]
|
||||||
|
if (!v) return ''
|
||||||
|
return Array.isArray(v) ? v[0] ?? '' : v
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBuiltin = (col: string): col is BuiltinColumn =>
|
||||||
|
(BUILTIN_COLUMNS as readonly string[]).includes(col)
|
||||||
|
|
||||||
|
export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave }: BasesViewProps) {
|
||||||
|
// Build notes instantly from tree
|
||||||
|
const notes = useMemo<NoteEntry[]>(() => {
|
||||||
|
return collectFiles(tree).map((f) => ({
|
||||||
|
path: f.path,
|
||||||
|
name: f.name,
|
||||||
|
folder: getFolder(f.path),
|
||||||
|
fields: {},
|
||||||
|
mtimeMs: f.mtimeMs,
|
||||||
|
}))
|
||||||
|
}, [tree])
|
||||||
|
|
||||||
|
// Frontmatter fields loaded async, keyed by path
|
||||||
|
const [fieldsByPath, setFieldsByPath] = useState<Map<string, Record<string, string | string[]>>>(new Map())
|
||||||
|
const loadGenRef = useRef(0)
|
||||||
|
|
||||||
|
// Load frontmatter in background batches
|
||||||
|
useEffect(() => {
|
||||||
|
const gen = ++loadGenRef.current
|
||||||
|
let cancelled = false
|
||||||
|
const paths = notes.map((n) => n.path)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const BATCH = 30
|
||||||
|
for (let i = 0; i < paths.length; i += BATCH) {
|
||||||
|
if (cancelled) return
|
||||||
|
const batch = paths.slice(i, i + BATCH)
|
||||||
|
const results = await Promise.all(
|
||||||
|
batch.map(async (p) => {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('workspace:readFile', { path: p, encoding: 'utf8' })
|
||||||
|
const { raw } = splitFrontmatter(result.data)
|
||||||
|
return { path: p, fields: extractAllFrontmatterValues(raw) }
|
||||||
|
} catch {
|
||||||
|
return { path: p, fields: {} as Record<string, string | string[]> }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (cancelled || gen !== loadGenRef.current) return
|
||||||
|
setFieldsByPath((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
for (const r of results) next.set(r.path, r.fields)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [notes])
|
||||||
|
|
||||||
|
// Merge tree-derived notes with async-loaded fields
|
||||||
|
const enrichedNotes = useMemo<NoteEntry[]>(() => {
|
||||||
|
if (fieldsByPath.size === 0) return notes
|
||||||
|
return notes.map((n) => {
|
||||||
|
const f = fieldsByPath.get(n.path)
|
||||||
|
return f ? { ...n, fields: f } : n
|
||||||
|
})
|
||||||
|
}, [notes, fieldsByPath])
|
||||||
|
|
||||||
|
// Collect all unique frontmatter property keys across all notes
|
||||||
|
const allPropertyKeys = useMemo<string[]>(() => {
|
||||||
|
const keys = new Set<string>()
|
||||||
|
for (const fields of fieldsByPath.values()) {
|
||||||
|
for (const k of Object.keys(fields)) keys.add(k)
|
||||||
|
}
|
||||||
|
return Array.from(keys).sort()
|
||||||
|
}, [fieldsByPath])
|
||||||
|
|
||||||
|
// Filterable categories: "folder" + all frontmatter keys
|
||||||
|
const filterCategories = useMemo<string[]>(() => {
|
||||||
|
return ['folder', ...allPropertyKeys]
|
||||||
|
}, [allPropertyKeys])
|
||||||
|
|
||||||
|
// All unique values per category, across all enriched notes
|
||||||
|
const valuesByCategory = useMemo<Record<string, string[]>>(() => {
|
||||||
|
const result: Record<string, Set<string>> = {}
|
||||||
|
for (const cat of filterCategories) result[cat] = new Set()
|
||||||
|
for (const note of enrichedNotes) {
|
||||||
|
for (const cat of filterCategories) {
|
||||||
|
for (const v of getColumnValues(note, cat)) {
|
||||||
|
if (v) result[cat]?.add(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: Record<string, string[]> = {}
|
||||||
|
for (const [cat, set] of Object.entries(result)) {
|
||||||
|
out[cat] = Array.from(set).sort((a, b) => a.localeCompare(b))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [filterCategories, enrichedNotes])
|
||||||
|
|
||||||
|
const visibleColumns = config.visibleColumns
|
||||||
|
const columnWidths = config.columnWidths
|
||||||
|
const filters = config.filters
|
||||||
|
const sortField = config.sort.field
|
||||||
|
const sortDir = config.sort.dir
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
|
||||||
|
const [saveName, setSaveName] = useState('')
|
||||||
|
const saveInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [filterCategory, setFilterCategory] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSaveClick = useCallback(() => {
|
||||||
|
if (isDefaultBase) {
|
||||||
|
setSaveName('')
|
||||||
|
setSaveDialogOpen(true)
|
||||||
|
} else {
|
||||||
|
onSave(null)
|
||||||
|
}
|
||||||
|
}, [isDefaultBase, onSave])
|
||||||
|
|
||||||
|
const handleSaveConfirm = useCallback(() => {
|
||||||
|
const name = saveName.trim()
|
||||||
|
if (!name) return
|
||||||
|
setSaveDialogOpen(false)
|
||||||
|
onSave(name)
|
||||||
|
}, [saveName, onSave])
|
||||||
|
|
||||||
|
const getColWidth = useCallback((col: string) => {
|
||||||
|
return columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH
|
||||||
|
}, [columnWidths])
|
||||||
|
|
||||||
|
// Column resize via drag
|
||||||
|
const resizingRef = useRef<{ col: string; startX: number; startW: number } | null>(null)
|
||||||
|
|
||||||
|
const configRef = useRef(config)
|
||||||
|
configRef.current = config
|
||||||
|
|
||||||
|
const onResizeStart = useCallback((col: string, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const startX = e.clientX
|
||||||
|
const startW = configRef.current.columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH
|
||||||
|
resizingRef.current = { col, startX, startW }
|
||||||
|
|
||||||
|
const onMouseMove = (ev: MouseEvent) => {
|
||||||
|
if (!resizingRef.current) return
|
||||||
|
const delta = ev.clientX - resizingRef.current.startX
|
||||||
|
const newW = Math.max(60, resizingRef.current.startW + delta)
|
||||||
|
const c = configRef.current
|
||||||
|
const updated = { ...c, columnWidths: { ...c.columnWidths, [resizingRef.current!.col]: newW } }
|
||||||
|
onConfigChange(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
resizingRef.current = null
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
}, [onConfigChange])
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const debouncedSearch = useDebounce(searchQuery, 250)
|
||||||
|
const [searchMatchPaths, setSearchMatchPaths] = useState<Set<string> | null>(null)
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debouncedSearch.trim()) {
|
||||||
|
setSearchMatchPaths(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
window.ipc.invoke('search:query', { query: debouncedSearch, limit: 200, types: ['knowledge'] })
|
||||||
|
.then((res: { results: { path: string }[] }) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSearchMatchPaths(new Set(res.results.map((r) => r.path)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setSearchMatchPaths(new Set())
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [debouncedSearch])
|
||||||
|
|
||||||
|
const toggleSearch = useCallback(() => {
|
||||||
|
setSearchOpen((prev) => {
|
||||||
|
if (prev) {
|
||||||
|
setSearchQuery('')
|
||||||
|
setSearchMatchPaths(null)
|
||||||
|
}
|
||||||
|
return !prev
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Focus input when search opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchOpen) searchInputRef.current?.focus()
|
||||||
|
}, [searchOpen])
|
||||||
|
|
||||||
|
// Reset page when filters or search change
|
||||||
|
useEffect(() => { setPage(0) }, [filters, searchMatchPaths])
|
||||||
|
|
||||||
|
// Filter (search + badge filters)
|
||||||
|
const filteredNotes = useMemo(() => {
|
||||||
|
let result = enrichedNotes
|
||||||
|
// Apply search filter
|
||||||
|
if (searchMatchPaths) {
|
||||||
|
result = result.filter((note) => searchMatchPaths.has(note.path))
|
||||||
|
}
|
||||||
|
// Apply badge filters
|
||||||
|
if (filters.length > 0) {
|
||||||
|
const byCategory = new Map<string, string[]>()
|
||||||
|
for (const f of filters) {
|
||||||
|
const vals = byCategory.get(f.category) ?? []
|
||||||
|
vals.push(f.value)
|
||||||
|
byCategory.set(f.category, vals)
|
||||||
|
}
|
||||||
|
result = result.filter((note) => {
|
||||||
|
for (const [category, requiredValues] of byCategory) {
|
||||||
|
const noteValues = getColumnValues(note, category)
|
||||||
|
if (!requiredValues.some((v) => noteValues.includes(v))) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, [enrichedNotes, filters, searchMatchPaths])
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
const sortedNotes = useMemo(() => {
|
||||||
|
return [...filteredNotes].sort((a, b) => {
|
||||||
|
const va = getSortValue(a, sortField)
|
||||||
|
const vb = getSortValue(b, sortField)
|
||||||
|
let cmp: number
|
||||||
|
if (typeof va === 'number' && typeof vb === 'number') {
|
||||||
|
cmp = va - vb
|
||||||
|
} else {
|
||||||
|
cmp = String(va).localeCompare(String(vb))
|
||||||
|
}
|
||||||
|
return sortDir === 'asc' ? cmp : -cmp
|
||||||
|
})
|
||||||
|
}, [filteredNotes, sortField, sortDir])
|
||||||
|
|
||||||
|
// Paginate
|
||||||
|
const totalPages = Math.max(1, Math.ceil(sortedNotes.length / PAGE_SIZE))
|
||||||
|
const clampedPage = Math.min(page, totalPages - 1)
|
||||||
|
const pageNotes = useMemo(
|
||||||
|
() => sortedNotes.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE),
|
||||||
|
[sortedNotes, clampedPage],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleFilter = useCallback((category: string, value: string) => {
|
||||||
|
const c = configRef.current
|
||||||
|
const f: ActiveFilter = { category, value }
|
||||||
|
const next = hasFilter(c.filters, f)
|
||||||
|
? c.filters.filter((x) => !filtersEqual(x, f))
|
||||||
|
: [...c.filters, f]
|
||||||
|
onConfigChange({ ...c, filters: next })
|
||||||
|
}, [onConfigChange])
|
||||||
|
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
onConfigChange({ ...configRef.current, filters: [] })
|
||||||
|
}, [onConfigChange])
|
||||||
|
|
||||||
|
const handleSort = useCallback((field: string) => {
|
||||||
|
const c = configRef.current
|
||||||
|
if (field === c.sort.field) {
|
||||||
|
onConfigChange({ ...c, sort: { field, dir: c.sort.dir === 'asc' ? 'desc' : 'asc' } })
|
||||||
|
} else {
|
||||||
|
onConfigChange({ ...c, sort: { field, dir: field === 'mtimeMs' ? 'desc' : 'asc' } })
|
||||||
|
}
|
||||||
|
}, [onConfigChange])
|
||||||
|
|
||||||
|
const toggleColumn = useCallback((key: string) => {
|
||||||
|
const c = configRef.current
|
||||||
|
const next = c.visibleColumns.includes(key)
|
||||||
|
? c.visibleColumns.filter((col) => col !== key)
|
||||||
|
: [...c.visibleColumns, key]
|
||||||
|
onConfigChange({ ...c, visibleColumns: next })
|
||||||
|
}, [onConfigChange])
|
||||||
|
|
||||||
|
const SortIcon = ({ field }: { field: string }) => {
|
||||||
|
if (sortField !== field) return null
|
||||||
|
return sortDir === 'asc'
|
||||||
|
? <ArrowUp className="size-3 inline ml-1" />
|
||||||
|
: <ArrowDown className="size-3 inline ml-1" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="shrink-0 border-b border-border px-4 py-2 flex items-center gap-3">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
<ListFilter className="size-3.5" />
|
||||||
|
Properties
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-56 p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search properties..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No properties found.</CommandEmpty>
|
||||||
|
<CommandGroup heading="Built-in">
|
||||||
|
{BUILTIN_COLUMNS.map((col) => (
|
||||||
|
<CommandItem key={col} onSelect={() => toggleColumn(col)}>
|
||||||
|
<Check className={cn('size-3.5 mr-2', visibleColumns.includes(col) ? 'opacity-100' : 'opacity-0')} />
|
||||||
|
{BUILTIN_LABELS[col]}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandGroup heading="Frontmatter">
|
||||||
|
{allPropertyKeys.map((key) => (
|
||||||
|
<CommandItem key={key} onSelect={() => toggleColumn(key)}>
|
||||||
|
<Check className={cn('size-3.5 mr-2', visibleColumns.includes(key) ? 'opacity-100' : 'opacity-0')} />
|
||||||
|
{toTitleCase(key)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Popover onOpenChange={(open) => { if (!open) setFilterCategory(null) }}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground',
|
||||||
|
filters.length > 0 && 'text-foreground',
|
||||||
|
)}>
|
||||||
|
<Filter className="size-3.5" />
|
||||||
|
Filter
|
||||||
|
{filters.length > 0 && (
|
||||||
|
<span className="rounded-full bg-primary text-primary-foreground px-1.5 text-[10px] font-medium leading-tight">
|
||||||
|
{filters.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className={cn('p-0', filterCategory ? 'w-[420px]' : 'w-[200px]')}>
|
||||||
|
<div className="flex h-[300px]">
|
||||||
|
{/* Left: categories */}
|
||||||
|
<div className={cn('overflow-auto', filterCategory ? 'w-[160px] border-r border-border' : 'flex-1')}>
|
||||||
|
<div className="flex items-center justify-between px-2 py-1.5">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Attributes</span>
|
||||||
|
{filters.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="text-[10px] text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{filterCategories.map((cat) => {
|
||||||
|
const activeCount = filters.filter((f) => f.category === cat).length
|
||||||
|
const isSelected = filterCategory === cat
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setFilterCategory(cat)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-1.5 px-2 py-1.5 text-xs text-left hover:bg-accent transition-colors',
|
||||||
|
isSelected && 'bg-accent text-foreground',
|
||||||
|
!isSelected && 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{toTitleCase(cat)}</span>
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="rounded-full bg-primary text-primary-foreground px-1.5 text-[10px] font-medium leading-tight shrink-0">
|
||||||
|
{activeCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Right: values for selected category */}
|
||||||
|
{filterCategory && (
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
|
<Command className="flex-1 flex flex-col">
|
||||||
|
<CommandInput placeholder={`Search ${toTitleCase(filterCategory).toLowerCase()}...`} />
|
||||||
|
<CommandList className="flex-1 overflow-auto max-h-none">
|
||||||
|
<CommandEmpty>No values found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{(valuesByCategory[filterCategory] ?? []).map((val) => {
|
||||||
|
const active = hasFilter(filters, { category: filterCategory, value: val })
|
||||||
|
return (
|
||||||
|
<CommandItem key={val} onSelect={() => toggleFilter(filterCategory, val)}>
|
||||||
|
<Check className={cn('size-3.5 mr-2 shrink-0', active ? 'opacity-100' : 'opacity-0')} />
|
||||||
|
<span className="truncate">{val}</span>
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleSearch}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0',
|
||||||
|
searchOpen && 'text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Search className="size-3.5" />
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{searchOpen && (
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search notes..."
|
||||||
|
className="flex-1 min-w-0 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||||
|
{searchMatchPaths ? `${searchMatchPaths.size} matches` : '...'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={toggleSearch}
|
||||||
|
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSaveClick}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0"
|
||||||
|
>
|
||||||
|
<Save className="size-3.5" />
|
||||||
|
{isDefaultBase ? 'Save As' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter bar */}
|
||||||
|
{filters.length > 0 && (
|
||||||
|
<div className="shrink-0 border-b border-border px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{sortedNotes.length} of {enrichedNotes.length} notes
|
||||||
|
</span>
|
||||||
|
{filters.map((f) => (
|
||||||
|
<button
|
||||||
|
key={`${f.category}:${f.value}`}
|
||||||
|
onClick={() => toggleFilter(f.category, f.value)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-primary text-primary-foreground px-2 py-0.5 text-[11px] font-medium"
|
||||||
|
>
|
||||||
|
<span className="text-primary-foreground/60">{f.category}:</span>
|
||||||
|
{f.value}
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button onClick={clearFilters} className="text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<table className="w-full text-sm" style={{ tableLayout: 'fixed' }}>
|
||||||
|
<colgroup>
|
||||||
|
{visibleColumns.map((col) => (
|
||||||
|
<col key={col} style={{ width: getColWidth(col) }} />
|
||||||
|
))}
|
||||||
|
</colgroup>
|
||||||
|
<thead className="sticky top-0 bg-background border-b border-border z-10">
|
||||||
|
<tr>
|
||||||
|
{visibleColumns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className="relative text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none group"
|
||||||
|
onClick={() => handleSort(col)}
|
||||||
|
>
|
||||||
|
<span className="truncate block">{toTitleCase(col)}<SortIcon field={col} /></span>
|
||||||
|
{/* Resize handle */}
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize opacity-0 group-hover:opacity-100 hover:!opacity-100 bg-border/60"
|
||||||
|
onMouseDown={(e) => onResizeStart(col, e)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pageNotes.map((note) => (
|
||||||
|
<tr
|
||||||
|
key={note.path}
|
||||||
|
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||||
|
onClick={() => onSelectNote(note.path)}
|
||||||
|
>
|
||||||
|
{visibleColumns.map((col) => (
|
||||||
|
<td key={col} className="px-4 py-2 overflow-hidden">
|
||||||
|
<CellRenderer
|
||||||
|
note={note}
|
||||||
|
column={col}
|
||||||
|
filters={filters}
|
||||||
|
toggleFilter={toggleFilter}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{pageNotes.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={visibleColumns.length} className="px-4 py-8 text-center text-muted-foreground">
|
||||||
|
No notes found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="shrink-0 border-t border-border px-4 py-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{sortedNotes.length === 0
|
||||||
|
? '0 notes'
|
||||||
|
: `${clampedPage * PAGE_SIZE + 1}\u2013${Math.min((clampedPage + 1) * PAGE_SIZE, sortedNotes.length)} of ${sortedNotes.length}`}
|
||||||
|
</span>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
disabled={clampedPage === 0}
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground px-2">
|
||||||
|
Page {clampedPage + 1} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={clampedPage >= totalPages - 1}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save As dialog */}
|
||||||
|
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[360px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Save Base</DialogTitle>
|
||||||
|
<DialogDescription>Choose a name for this base view.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<input
|
||||||
|
ref={saveInputRef}
|
||||||
|
type="text"
|
||||||
|
value={saveName}
|
||||||
|
onChange={(e) => setSaveName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveConfirm() }}
|
||||||
|
placeholder="e.g. Contacts, Projects..."
|
||||||
|
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
onClick={() => setSaveDialogOpen(false)}
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveConfirm}
|
||||||
|
disabled={!saveName.trim()}
|
||||||
|
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a single table cell based on the column type */
|
||||||
|
function CellRenderer({
|
||||||
|
note,
|
||||||
|
column,
|
||||||
|
filters,
|
||||||
|
toggleFilter,
|
||||||
|
}: {
|
||||||
|
note: NoteEntry
|
||||||
|
column: string
|
||||||
|
filters: ActiveFilter[]
|
||||||
|
toggleFilter: (category: string, value: string) => void
|
||||||
|
}) {
|
||||||
|
if (column === 'name') {
|
||||||
|
return <span className="font-medium truncate block">{note.name}</span>
|
||||||
|
}
|
||||||
|
if (column === 'folder') {
|
||||||
|
return <span className="text-muted-foreground truncate block">{note.folder}</span>
|
||||||
|
}
|
||||||
|
if (column === 'mtimeMs') {
|
||||||
|
return <span className="text-muted-foreground whitespace-nowrap truncate block">{formatDate(note.mtimeMs)}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontmatter column
|
||||||
|
const value = note.fields[column]
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{value.map((v) => (
|
||||||
|
<CategoryBadge
|
||||||
|
key={v}
|
||||||
|
category={column}
|
||||||
|
value={v}
|
||||||
|
active={hasFilter(filters, { category: column, value: v })}
|
||||||
|
onClick={toggleFilter}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single string value — render as badge for filterability
|
||||||
|
return (
|
||||||
|
<CategoryBadge
|
||||||
|
category={column}
|
||||||
|
value={value}
|
||||||
|
active={hasFilter(filters, { category: column, value })}
|
||||||
|
onClick={toggleFilter}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryBadge({
|
||||||
|
category,
|
||||||
|
value,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
category: string
|
||||||
|
value: string
|
||||||
|
active: boolean
|
||||||
|
onClick: (category: string, value: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={active ? 'default' : 'secondary'}
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] px-1.5 py-0 cursor-pointer',
|
||||||
|
!active && 'hover:bg-primary hover:text-primary-foreground',
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClick(category, value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,36 @@
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { ArrowUp, LoaderIcon, Square } from 'lucide-react'
|
import {
|
||||||
|
ArrowUp,
|
||||||
|
AudioLines,
|
||||||
|
ChevronDown,
|
||||||
|
FileArchive,
|
||||||
|
FileCode2,
|
||||||
|
FileIcon,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileText,
|
||||||
|
FileVideo,
|
||||||
|
LoaderIcon,
|
||||||
|
Plus,
|
||||||
|
Square,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
type AttachmentIconKind,
|
||||||
|
getAttachmentDisplayName,
|
||||||
|
getAttachmentIconKind,
|
||||||
|
getAttachmentToneClass,
|
||||||
|
getAttachmentTypeLabel,
|
||||||
|
} from '@/lib/attachment-presentation'
|
||||||
|
import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
type FileMention,
|
type FileMention,
|
||||||
|
|
@ -10,9 +39,60 @@ import {
|
||||||
PromptInputTextarea,
|
PromptInputTextarea,
|
||||||
usePromptInputController,
|
usePromptInputController,
|
||||||
} from '@/components/ai-elements/prompt-input'
|
} from '@/components/ai-elements/prompt-input'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export type StagedAttachment = {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
filename: string
|
||||||
|
mimeType: string
|
||||||
|
isImage: boolean
|
||||||
|
size: number
|
||||||
|
thumbnailUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
|
|
||||||
|
const providerDisplayNames: Record<string, string> = {
|
||||||
|
openai: 'OpenAI',
|
||||||
|
anthropic: 'Anthropic',
|
||||||
|
google: 'Gemini',
|
||||||
|
ollama: 'Ollama',
|
||||||
|
openrouter: 'OpenRouter',
|
||||||
|
aigateway: 'AI Gateway',
|
||||||
|
'openai-compatible': 'OpenAI-Compatible',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfiguredModel {
|
||||||
|
flavor: string
|
||||||
|
model: string
|
||||||
|
apiKey?: string
|
||||||
|
baseURL?: string
|
||||||
|
headers?: Record<string, string>
|
||||||
|
knowledgeGraphModel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||||
|
switch (kind) {
|
||||||
|
case 'audio':
|
||||||
|
return AudioLines
|
||||||
|
case 'video':
|
||||||
|
return FileVideo
|
||||||
|
case 'spreadsheet':
|
||||||
|
return FileSpreadsheet
|
||||||
|
case 'archive':
|
||||||
|
return FileArchive
|
||||||
|
case 'code':
|
||||||
|
return FileCode2
|
||||||
|
case 'text':
|
||||||
|
return FileText
|
||||||
|
default:
|
||||||
|
return FileIcon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatInputInnerProps {
|
interface ChatInputInnerProps {
|
||||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||||
onStop?: () => void
|
onStop?: () => void
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
isStopping?: boolean
|
isStopping?: boolean
|
||||||
|
|
@ -38,7 +118,94 @@ function ChatInputInner({
|
||||||
}: ChatInputInnerProps) {
|
}: ChatInputInnerProps) {
|
||||||
const controller = usePromptInputController()
|
const controller = usePromptInputController()
|
||||||
const message = controller.textInput.value
|
const message = controller.textInput.value
|
||||||
const canSubmit = Boolean(message.trim()) && !isProcessing
|
const [attachments, setAttachments] = useState<StagedAttachment[]>([])
|
||||||
|
const [focusNonce, setFocusNonce] = useState(0)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
|
||||||
|
|
||||||
|
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
|
||||||
|
const [activeModelKey, setActiveModelKey] = useState('')
|
||||||
|
|
||||||
|
// Load model config from disk (on mount and whenever tab becomes active)
|
||||||
|
const loadModelConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||||
|
const parsed = JSON.parse(result.data)
|
||||||
|
const models: ConfiguredModel[] = []
|
||||||
|
if (parsed?.providers) {
|
||||||
|
for (const [flavor, entry] of Object.entries(parsed.providers)) {
|
||||||
|
const e = entry as Record<string, unknown>
|
||||||
|
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
|
||||||
|
const singleModel = typeof e.model === 'string' ? e.model : ''
|
||||||
|
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
||||||
|
for (const model of allModels) {
|
||||||
|
if (model) {
|
||||||
|
models.push({
|
||||||
|
flavor,
|
||||||
|
model,
|
||||||
|
apiKey: (e.apiKey as string) || undefined,
|
||||||
|
baseURL: (e.baseURL as string) || undefined,
|
||||||
|
headers: (e.headers as Record<string, string>) || undefined,
|
||||||
|
knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const defaultKey = parsed?.provider?.flavor && parsed?.model
|
||||||
|
? `${parsed.provider.flavor}/${parsed.model}`
|
||||||
|
: ''
|
||||||
|
models.sort((a, b) => {
|
||||||
|
const aKey = `${a.flavor}/${a.model}`
|
||||||
|
const bKey = `${b.flavor}/${b.model}`
|
||||||
|
if (aKey === defaultKey) return -1
|
||||||
|
if (bKey === defaultKey) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
setConfiguredModels(models)
|
||||||
|
if (defaultKey) {
|
||||||
|
setActiveModelKey(defaultKey)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No config yet
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadModelConfig()
|
||||||
|
}, [isActive, loadModelConfig])
|
||||||
|
|
||||||
|
// Reload when model config changes (e.g. from settings dialog)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => { loadModelConfig() }
|
||||||
|
window.addEventListener('models-config-changed', handler)
|
||||||
|
return () => window.removeEventListener('models-config-changed', handler)
|
||||||
|
}, [loadModelConfig])
|
||||||
|
|
||||||
|
const handleModelChange = useCallback(async (key: string) => {
|
||||||
|
const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key)
|
||||||
|
if (!entry) return
|
||||||
|
setActiveModelKey(key)
|
||||||
|
// Collect all models for this provider so the full list is preserved
|
||||||
|
const providerModels = configuredModels
|
||||||
|
.filter((m) => m.flavor === entry.flavor)
|
||||||
|
.map((m) => m.model)
|
||||||
|
try {
|
||||||
|
await window.ipc.invoke('models:saveConfig', {
|
||||||
|
provider: {
|
||||||
|
flavor: entry.flavor,
|
||||||
|
apiKey: entry.apiKey,
|
||||||
|
baseURL: entry.baseURL,
|
||||||
|
headers: entry.headers,
|
||||||
|
},
|
||||||
|
model: entry.model,
|
||||||
|
models: providerModels,
|
||||||
|
knowledgeGraphModel: entry.knowledgeGraphModel,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to switch model')
|
||||||
|
}
|
||||||
|
}, [configuredModels])
|
||||||
|
|
||||||
// Restore the tab draft when this input mounts.
|
// Restore the tab draft when this input mounts.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -59,12 +226,48 @@ function ChatInputInner({
|
||||||
}
|
}
|
||||||
}, [presetMessage, controller.textInput, onPresetMessageConsumed])
|
}, [presetMessage, controller.textInput, onPresetMessageConsumed])
|
||||||
|
|
||||||
|
const addFiles = useCallback(async (paths: string[]) => {
|
||||||
|
const newAttachments: StagedAttachment[] = []
|
||||||
|
for (const filePath of paths) {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath })
|
||||||
|
if (result.size > MAX_ATTACHMENT_SIZE) {
|
||||||
|
toast.error(`File too large: ${getFileDisplayName(filePath)} (max 10MB)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const mime = result.mimeType || getMimeFromExtension(getExtension(filePath))
|
||||||
|
const image = isImageMime(mime)
|
||||||
|
newAttachments.push({
|
||||||
|
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
path: filePath,
|
||||||
|
filename: getFileDisplayName(filePath),
|
||||||
|
mimeType: mime,
|
||||||
|
isImage: image,
|
||||||
|
size: result.size,
|
||||||
|
thumbnailUrl: image ? `data:${mime};base64,${result.data}` : undefined,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to read file:', filePath, err)
|
||||||
|
toast.error(`Failed to read: ${getFileDisplayName(filePath)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newAttachments.length > 0) {
|
||||||
|
setAttachments((prev) => [...prev, ...newAttachments])
|
||||||
|
setFocusNonce((value) => value + 1)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeAttachment = useCallback((id: string) => {
|
||||||
|
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (!canSubmit) return
|
if (!canSubmit) return
|
||||||
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions)
|
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments)
|
||||||
controller.textInput.clear()
|
controller.textInput.clear()
|
||||||
controller.mentions.clearMentions()
|
controller.mentions.clearMentions()
|
||||||
}, [canSubmit, message, onSubmit, controller])
|
setAttachments([])
|
||||||
|
}, [attachments, canSubmit, controller, message, onSubmit])
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
|
@ -88,11 +291,9 @@ function ChatInputInner({
|
||||||
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
||||||
const paths = Array.from(e.dataTransfer.files)
|
const paths = Array.from(e.dataTransfer.files)
|
||||||
.map((file) => window.electronUtils?.getPathForFile(file))
|
.map((file) => window.electronUtils?.getPathForFile(file))
|
||||||
.filter(Boolean)
|
.filter(Boolean) as string[]
|
||||||
if (paths.length > 0) {
|
if (paths.length > 0) {
|
||||||
const currentText = controller.textInput.value
|
void addFiles(paths)
|
||||||
const pathText = paths.join(' ')
|
|
||||||
controller.textInput.setInput(currentText ? `${currentText} ${pathText}` : pathText)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -103,50 +304,150 @@ function ChatInputInner({
|
||||||
document.removeEventListener('dragover', onDragOver)
|
document.removeEventListener('dragover', onDragOver)
|
||||||
document.removeEventListener('drop', onDrop)
|
document.removeEventListener('drop', onDrop)
|
||||||
}
|
}
|
||||||
}, [controller, isActive])
|
}, [addFiles, isActive])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-4 shadow-none">
|
<div className="rounded-lg border border-border bg-background shadow-none">
|
||||||
<PromptInputTextarea
|
{attachments.length > 0 && (
|
||||||
placeholder="Type your message..."
|
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||||
onKeyDown={handleKeyDown}
|
{attachments.map((attachment) => {
|
||||||
autoFocus={isActive}
|
const attachmentType = getAttachmentTypeLabel(attachment)
|
||||||
focusTrigger={isActive ? runId : undefined}
|
const attachmentName = getAttachmentDisplayName(attachment)
|
||||||
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
|
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
|
||||||
/>
|
|
||||||
{isProcessing ? (
|
return (
|
||||||
<Button
|
<span
|
||||||
size="icon"
|
key={attachment.id}
|
||||||
onClick={onStop}
|
className="group relative inline-flex min-w-[230px] max-w-[320px] items-center gap-2 rounded-xl border border-border/50 bg-muted/80 px-2.5 py-2"
|
||||||
title={isStopping ? 'Click again to force stop' : 'Stop generation'}
|
>
|
||||||
className={cn(
|
<span
|
||||||
'h-7 w-7 shrink-0 rounded-full transition-all',
|
className={cn(
|
||||||
isStopping
|
'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg',
|
||||||
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
attachment.isImage && attachment.thumbnailUrl
|
||||||
: 'bg-primary text-primary-foreground hover:bg-primary/90'
|
? 'bg-muted'
|
||||||
)}
|
: getAttachmentToneClass(attachmentType)
|
||||||
>
|
)}
|
||||||
{isStopping ? (
|
>
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
{attachment.isImage && attachment.thumbnailUrl ? (
|
||||||
) : (
|
<img src={attachment.thumbnailUrl} alt="" className="size-full object-cover" />
|
||||||
<Square className="h-3 w-3 fill-current" />
|
) : (
|
||||||
)}
|
<Icon className="size-5" />
|
||||||
</Button>
|
)}
|
||||||
) : (
|
</span>
|
||||||
<Button
|
<span className="min-w-0 flex-1">
|
||||||
size="icon"
|
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
|
||||||
onClick={handleSubmit}
|
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
|
||||||
disabled={!canSubmit}
|
</span>
|
||||||
className={cn(
|
<button
|
||||||
'h-7 w-7 shrink-0 rounded-full transition-all',
|
type="button"
|
||||||
canSubmit
|
onClick={() => removeAttachment(attachment.id)}
|
||||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
className="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full border border-border/70 bg-background/70 text-muted-foreground opacity-0 transition-[opacity,color] duration-150 hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
: 'bg-muted text-muted-foreground'
|
>
|
||||||
)}
|
<X className="size-3.5" />
|
||||||
>
|
</button>
|
||||||
<ArrowUp className="h-4 w-4" />
|
</span>
|
||||||
</Button>
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
const paths = Array.from(files)
|
||||||
|
.map((file) => window.electronUtils?.getPathForFile(file))
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
if (paths.length > 0) {
|
||||||
|
void addFiles(paths)
|
||||||
|
}
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="px-4 pt-4 pb-2">
|
||||||
|
<PromptInputTextarea
|
||||||
|
placeholder="Type your message..."
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoFocus={isActive}
|
||||||
|
focusTrigger={isActive ? `${runId ?? 'new'}:${focusNonce}` : undefined}
|
||||||
|
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-4 pb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
aria-label="Attach files"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{configuredModels.length > 0 && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<span className="max-w-[150px] truncate">
|
||||||
|
{configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || 'Model'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}>
|
||||||
|
{configuredModels.map((m) => {
|
||||||
|
const key = `${m.flavor}/${m.model}`
|
||||||
|
return (
|
||||||
|
<DropdownMenuRadioItem key={key} value={key}>
|
||||||
|
<span className="truncate">{m.model}</span>
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.flavor] || m.flavor}</span>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
{isProcessing ? (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
onClick={onStop}
|
||||||
|
title={isStopping ? 'Click again to force stop' : 'Stop generation'}
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||||
|
isStopping
|
||||||
|
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||||
|
: 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isStopping ? (
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Square className="h-3 w-3 fill-current" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||||
|
canSubmit
|
||||||
|
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +456,7 @@ export interface ChatInputWithMentionsProps {
|
||||||
knowledgeFiles: string[]
|
knowledgeFiles: string[]
|
||||||
recentFiles: string[]
|
recentFiles: string[]
|
||||||
visibleFiles: string[]
|
visibleFiles: string[]
|
||||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||||
onStop?: () => void
|
onStop?: () => void
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
isStopping?: boolean
|
isStopping?: boolean
|
||||||
|
|
|
||||||
137
apps/x/apps/renderer/src/components/chat-message-attachments.tsx
Normal file
137
apps/x/apps/renderer/src/components/chat-message-attachments.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import {
|
||||||
|
AudioLines,
|
||||||
|
FileArchive,
|
||||||
|
FileCode2,
|
||||||
|
FileIcon,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileText,
|
||||||
|
FileVideo,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import type { MessageAttachment } from '@/lib/chat-conversation'
|
||||||
|
import {
|
||||||
|
type AttachmentIconKind,
|
||||||
|
getAttachmentDisplayName,
|
||||||
|
getAttachmentIconKind,
|
||||||
|
getAttachmentToneClass,
|
||||||
|
getAttachmentTypeLabel,
|
||||||
|
} from '@/lib/attachment-presentation'
|
||||||
|
import { isImageMime, toFileUrl } from '@/lib/file-utils'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||||
|
switch (kind) {
|
||||||
|
case 'audio':
|
||||||
|
return AudioLines
|
||||||
|
case 'video':
|
||||||
|
return FileVideo
|
||||||
|
case 'spreadsheet':
|
||||||
|
return FileSpreadsheet
|
||||||
|
case 'archive':
|
||||||
|
return FileArchive
|
||||||
|
case 'code':
|
||||||
|
return FileCode2
|
||||||
|
case 'text':
|
||||||
|
return FileText
|
||||||
|
default:
|
||||||
|
return FileIcon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageAttachmentPreview({ attachment }: { attachment: MessageAttachment }) {
|
||||||
|
const fallbackFileUrl = useMemo(() => toFileUrl(attachment.path), [attachment.path])
|
||||||
|
const [src, setSrc] = useState(attachment.thumbnailUrl || fallbackFileUrl)
|
||||||
|
const [triedBase64, setTriedBase64] = useState(Boolean(attachment.thumbnailUrl))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextSrc = attachment.thumbnailUrl || fallbackFileUrl
|
||||||
|
setSrc(nextSrc)
|
||||||
|
setTriedBase64(Boolean(attachment.thumbnailUrl))
|
||||||
|
}, [attachment.thumbnailUrl, fallbackFileUrl])
|
||||||
|
|
||||||
|
const loadBase64 = useMemo(
|
||||||
|
() => async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('shell:readFileBase64', { path: attachment.path })
|
||||||
|
const mimeType = result.mimeType || attachment.mimeType || 'image/*'
|
||||||
|
setSrc(`data:${mimeType};base64,${result.data}`)
|
||||||
|
} catch {
|
||||||
|
// Keep current src; fallback rendering (broken image icon) is better than crashing.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[attachment.mimeType, attachment.path]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (attachment.thumbnailUrl || triedBase64) return
|
||||||
|
setTriedBase64(true)
|
||||||
|
void loadBase64()
|
||||||
|
}, [attachment.thumbnailUrl, loadBase64, triedBase64])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt="Image attachment"
|
||||||
|
className="h-44 w-auto max-w-[300px] rounded-2xl border border-border/70 bg-muted object-cover"
|
||||||
|
onError={() => {
|
||||||
|
if (triedBase64) return
|
||||||
|
setTriedBase64(true)
|
||||||
|
void loadBase64()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessageAttachmentsProps {
|
||||||
|
attachments: MessageAttachment[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) {
|
||||||
|
if (attachments.length === 0) return null
|
||||||
|
|
||||||
|
const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType))
|
||||||
|
const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mimeType))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col items-end gap-2', className)}>
|
||||||
|
{imageAttachments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
{imageAttachments.map((attachment, index) => (
|
||||||
|
<ImageAttachmentPreview key={`${attachment.path}-${index}`} attachment={attachment} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fileAttachments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
{fileAttachments.map((attachment, index) => {
|
||||||
|
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
|
||||||
|
const attachmentName = getAttachmentDisplayName(attachment)
|
||||||
|
const attachmentType = getAttachmentTypeLabel(attachment)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${attachment.path}-${index}`}
|
||||||
|
className="inline-flex min-w-[240px] max-w-[440px] items-center gap-3 rounded-2xl border border-border/50 bg-muted/75 px-3 py-2.5 text-sm text-foreground"
|
||||||
|
title={attachmentName}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex size-12 shrink-0 items-center justify-center rounded-xl',
|
||||||
|
getAttachmentToneClass(attachmentType)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-6 shrink-0" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
|
||||||
|
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,8 @@ import { type PromptInputMessage, type FileMention } from '@/components/ai-eleme
|
||||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||||
import { TabBar, type ChatTab } from '@/components/tab-bar'
|
import { TabBar, type ChatTab } from '@/components/tab-bar'
|
||||||
import { ChatInputWithMentions } from '@/components/chat-input-with-mentions'
|
import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions'
|
||||||
|
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||||
import { wikiLabel } from '@/lib/wiki-links'
|
import { wikiLabel } from '@/lib/wiki-links'
|
||||||
import {
|
import {
|
||||||
type ChatTabViewState,
|
type ChatTabViewState,
|
||||||
|
|
@ -89,7 +90,7 @@ interface ChatSidebarProps {
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
isStopping?: boolean
|
isStopping?: boolean
|
||||||
onStop?: () => void
|
onStop?: () => void
|
||||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||||
knowledgeFiles?: string[]
|
knowledgeFiles?: string[]
|
||||||
recentFiles?: string[]
|
recentFiles?: string[]
|
||||||
visibleFiles?: string[]
|
visibleFiles?: string[]
|
||||||
|
|
@ -256,6 +257,18 @@ export function ChatSidebar({
|
||||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
||||||
if (isChatMessage(item)) {
|
if (isChatMessage(item)) {
|
||||||
if (item.role === 'user') {
|
if (item.role === 'user') {
|
||||||
|
if (item.attachments && item.attachments.length > 0) {
|
||||||
|
return (
|
||||||
|
<Message key={item.id} from={item.role}>
|
||||||
|
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
|
||||||
|
<ChatMessageAttachments attachments={item.attachments} />
|
||||||
|
</MessageContent>
|
||||||
|
{item.content && (
|
||||||
|
<MessageContent>{item.content}</MessageContent>
|
||||||
|
)}
|
||||||
|
</Message>
|
||||||
|
)
|
||||||
|
}
|
||||||
const { message, files } = parseAttachedFiles(item.content)
|
const { message, files } = parseAttachedFiles(item.content)
|
||||||
return (
|
return (
|
||||||
<Message key={item.id} from={item.role}>
|
<Message key={item.id} from={item.role}>
|
||||||
|
|
|
||||||
252
apps/x/apps/renderer/src/components/frontmatter-properties.tsx
Normal file
252
apps/x/apps/renderer/src/components/frontmatter-properties.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
|
import { ChevronRight, X, Plus } from 'lucide-react'
|
||||||
|
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
|
||||||
|
|
||||||
|
interface FrontmatterPropertiesProps {
|
||||||
|
raw: string | null
|
||||||
|
onRawChange: (raw: string | null) => void
|
||||||
|
editable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldEntry = { key: string; value: string | string[] }
|
||||||
|
|
||||||
|
function fieldsFromRaw(raw: string | null): FieldEntry[] {
|
||||||
|
const record = extractAllFrontmatterValues(raw)
|
||||||
|
return Object.entries(record).map(([key, value]) => ({ key, value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldsToRaw(fields: FieldEntry[]): string | null {
|
||||||
|
const record: Record<string, string | string[]> = {}
|
||||||
|
for (const { key, value } of fields) {
|
||||||
|
if (key.trim()) record[key.trim()] = value
|
||||||
|
}
|
||||||
|
return buildFrontmatter(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const [fields, setFields] = useState<FieldEntry[]>(() => fieldsFromRaw(raw))
|
||||||
|
const [editingNewKey, setEditingNewKey] = useState(false)
|
||||||
|
const newKeyRef = useRef<HTMLInputElement>(null)
|
||||||
|
const lastCommittedRaw = useRef(raw)
|
||||||
|
|
||||||
|
// Sync local fields when raw changes externally (e.g. tab switch)
|
||||||
|
useEffect(() => {
|
||||||
|
if (raw !== lastCommittedRaw.current) {
|
||||||
|
setFields(fieldsFromRaw(raw))
|
||||||
|
lastCommittedRaw.current = raw
|
||||||
|
}
|
||||||
|
}, [raw])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingNewKey && newKeyRef.current) {
|
||||||
|
newKeyRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [editingNewKey])
|
||||||
|
|
||||||
|
const commit = useCallback((updated: FieldEntry[]) => {
|
||||||
|
const newRaw = fieldsToRaw(updated)
|
||||||
|
lastCommittedRaw.current = newRaw
|
||||||
|
onRawChange(newRaw)
|
||||||
|
}, [onRawChange])
|
||||||
|
|
||||||
|
// For scalar fields: update local state immediately, commit on blur
|
||||||
|
const updateLocalValue = useCallback((index: number, newValue: string) => {
|
||||||
|
setFields(prev => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[index] = { ...next[index], value: newValue }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const commitField = useCallback((index: number) => {
|
||||||
|
setFields(prev => {
|
||||||
|
commit(prev)
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}, [commit])
|
||||||
|
|
||||||
|
// For array fields and structural changes: update + commit immediately
|
||||||
|
const updateAndCommit = useCallback((updater: (prev: FieldEntry[]) => FieldEntry[]) => {
|
||||||
|
setFields(prev => {
|
||||||
|
const next = updater(prev)
|
||||||
|
commit(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [commit])
|
||||||
|
|
||||||
|
const removeField = useCallback((index: number) => {
|
||||||
|
updateAndCommit(prev => prev.filter((_, i) => i !== index))
|
||||||
|
}, [updateAndCommit])
|
||||||
|
|
||||||
|
const addField = useCallback((key: string) => {
|
||||||
|
const trimmed = key.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
if (fields.some(f => f.key === trimmed)) return
|
||||||
|
updateAndCommit(prev => [...prev, { key: trimmed, value: '' }])
|
||||||
|
setEditingNewKey(false)
|
||||||
|
}, [fields, updateAndCommit])
|
||||||
|
|
||||||
|
const count = fields.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="frontmatter-properties">
|
||||||
|
<button
|
||||||
|
className="frontmatter-toggle"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
className={`frontmatter-chevron ${expanded ? 'expanded' : ''}`}
|
||||||
|
/>
|
||||||
|
<span className="frontmatter-label">
|
||||||
|
Properties{count > 0 ? ` (${count})` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="frontmatter-fields">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={`${field.key}-${index}`} className="frontmatter-row">
|
||||||
|
<span className="frontmatter-key" title={field.key}>
|
||||||
|
{field.key}
|
||||||
|
</span>
|
||||||
|
<div className="frontmatter-value-area">
|
||||||
|
{Array.isArray(field.value) ? (
|
||||||
|
<ArrayField
|
||||||
|
value={field.value}
|
||||||
|
editable={editable}
|
||||||
|
onChange={(v) => updateAndCommit(prev => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[index] = { ...next[index], value: v }
|
||||||
|
return next
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className="frontmatter-input"
|
||||||
|
value={field.value}
|
||||||
|
readOnly={!editable}
|
||||||
|
onChange={(e) => updateLocalValue(index, e.target.value)}
|
||||||
|
onBlur={() => commitField(index)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.blur()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
className="frontmatter-remove"
|
||||||
|
onClick={() => removeField(index)}
|
||||||
|
type="button"
|
||||||
|
title="Remove property"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
editingNewKey ? (
|
||||||
|
<div className="frontmatter-row frontmatter-new-row">
|
||||||
|
<input
|
||||||
|
ref={newKeyRef}
|
||||||
|
className="frontmatter-input frontmatter-new-key-input"
|
||||||
|
placeholder="Property name"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
addField(e.currentTarget.value)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setEditingNewKey(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (e.currentTarget.value.trim()) {
|
||||||
|
addField(e.currentTarget.value)
|
||||||
|
} else {
|
||||||
|
setEditingNewKey(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="frontmatter-add"
|
||||||
|
onClick={() => setEditingNewKey(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
<span>Add property</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArrayField({
|
||||||
|
value,
|
||||||
|
editable,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string[]
|
||||||
|
editable: boolean
|
||||||
|
onChange: (v: string[]) => void
|
||||||
|
}) {
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
onChange(value.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addItem = (text: string) => {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
onChange([...value, trimmed])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="frontmatter-array">
|
||||||
|
{value.map((item, i) => (
|
||||||
|
<span key={i} className="frontmatter-chip">
|
||||||
|
<span className="frontmatter-chip-text">{item}</span>
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
className="frontmatter-chip-remove"
|
||||||
|
onClick={() => removeItem(i)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{editable && (
|
||||||
|
<input
|
||||||
|
className="frontmatter-chip-input"
|
||||||
|
placeholder="Add..."
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault()
|
||||||
|
addItem(e.currentTarget.value)
|
||||||
|
e.currentTarget.value = ''
|
||||||
|
} else if (e.key === 'Backspace' && !e.currentTarget.value && value.length > 0) {
|
||||||
|
removeItem(value.length - 1)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (e.currentTarget.value.trim()) {
|
||||||
|
addItem(e.currentTarget.value)
|
||||||
|
e.currentTarget.value = ''
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
|
import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
|
||||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
import Image from '@tiptap/extension-image'
|
import Image from '@tiptap/extension-image'
|
||||||
|
|
@ -8,6 +8,7 @@ import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import TaskList from '@tiptap/extension-task-list'
|
import TaskList from '@tiptap/extension-task-list'
|
||||||
import TaskItem from '@tiptap/extension-task-item'
|
import TaskItem from '@tiptap/extension-task-item'
|
||||||
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
||||||
|
import { TaskBlockExtension } from '@/extensions/task-block'
|
||||||
import { Markdown } from 'tiptap-markdown'
|
import { Markdown } from 'tiptap-markdown'
|
||||||
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
|
@ -133,6 +134,8 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
blocks.push(listLines.join('\n'))
|
blocks.push(listLines.join('\n'))
|
||||||
|
} else if (node.type === 'taskBlock') {
|
||||||
|
blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```')
|
||||||
} else if (node.type === 'codeBlock') {
|
} else if (node.type === 'codeBlock') {
|
||||||
const lang = (node.attrs?.language as string) || ''
|
const lang = (node.attrs?.language as string) || ''
|
||||||
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
|
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
|
||||||
|
|
@ -176,12 +179,26 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
import { EditorToolbar } from './editor-toolbar'
|
import { EditorToolbar } from './editor-toolbar'
|
||||||
|
import { FrontmatterProperties } from './frontmatter-properties'
|
||||||
import { WikiLink } from '@/extensions/wiki-link'
|
import { WikiLink } from '@/extensions/wiki-link'
|
||||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||||
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
||||||
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
|
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
|
||||||
|
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
|
||||||
|
import { RowboatMentionPopover } from './rowboat-mention-popover'
|
||||||
import '@/styles/editor.css'
|
import '@/styles/editor.css'
|
||||||
|
|
||||||
|
type RowboatMentionMatch = {
|
||||||
|
range: { from: number; to: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
type RowboatBlockEdit = {
|
||||||
|
/** ProseMirror position of the taskBlock node */
|
||||||
|
nodePos: number
|
||||||
|
/** Existing instruction text */
|
||||||
|
existingText: string
|
||||||
|
}
|
||||||
|
|
||||||
type WikiLinkConfig = {
|
type WikiLinkConfig = {
|
||||||
files: string[]
|
files: string[]
|
||||||
recent: string[]
|
recent: string[]
|
||||||
|
|
@ -192,9 +209,16 @@ type WikiLinkConfig = {
|
||||||
interface MarkdownEditorProps {
|
interface MarkdownEditorProps {
|
||||||
content: string
|
content: string
|
||||||
onChange: (markdown: string) => void
|
onChange: (markdown: string) => void
|
||||||
|
onPrimaryHeadingCommit?: () => void
|
||||||
|
preserveUntitledTitleHeading?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
wikiLinks?: WikiLinkConfig
|
wikiLinks?: WikiLinkConfig
|
||||||
onImageUpload?: (file: File) => Promise<string | null>
|
onImageUpload?: (file: File) => Promise<string | null>
|
||||||
|
editorSessionKey?: number
|
||||||
|
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
|
||||||
|
editable?: boolean
|
||||||
|
frontmatter?: string | null
|
||||||
|
onFrontmatterChange?: (raw: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type WikiLinkMatch = {
|
type WikiLinkMatch = {
|
||||||
|
|
@ -275,9 +299,16 @@ const TabIndentExtension = Extension.create({
|
||||||
export function MarkdownEditor({
|
export function MarkdownEditor({
|
||||||
content,
|
content,
|
||||||
onChange,
|
onChange,
|
||||||
|
onPrimaryHeadingCommit,
|
||||||
|
preserveUntitledTitleHeading = false,
|
||||||
placeholder = 'Start writing...',
|
placeholder = 'Start writing...',
|
||||||
wikiLinks,
|
wikiLinks,
|
||||||
onImageUpload,
|
onImageUpload,
|
||||||
|
editorSessionKey = 0,
|
||||||
|
onHistoryHandlersChange,
|
||||||
|
editable = true,
|
||||||
|
frontmatter,
|
||||||
|
onFrontmatterChange,
|
||||||
}: MarkdownEditorProps) {
|
}: MarkdownEditorProps) {
|
||||||
const isInternalUpdate = useRef(false)
|
const isInternalUpdate = useRef(false)
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
@ -286,8 +317,20 @@ export function MarkdownEditor({
|
||||||
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
|
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
|
||||||
const selectionHighlightRef = useRef<SelectionHighlightRange>(null)
|
const selectionHighlightRef = useRef<SelectionHighlightRange>(null)
|
||||||
const [wikiCommandValue, setWikiCommandValue] = useState<string>('')
|
const [wikiCommandValue, setWikiCommandValue] = useState<string>('')
|
||||||
|
const onPrimaryHeadingCommitRef = useRef(onPrimaryHeadingCommit)
|
||||||
const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
|
const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
|
||||||
const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {})
|
const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {})
|
||||||
|
const [activeRowboatMention, setActiveRowboatMention] = useState<RowboatMentionMatch | null>(null)
|
||||||
|
const [rowboatBlockEdit, setRowboatBlockEdit] = useState<RowboatBlockEdit | null>(null)
|
||||||
|
const [rowboatAnchorTop, setRowboatAnchorTop] = useState<{ top: number; left: number; width: number } | null>(null)
|
||||||
|
const rowboatBlockEditRef = useRef<RowboatBlockEdit | null>(null)
|
||||||
|
|
||||||
|
// @ mention autocomplete state (analogous to wiki-link state)
|
||||||
|
const [activeAtMention, setActiveAtMention] = useState<{ range: { from: number; to: number }; query: string } | null>(null)
|
||||||
|
const [atAnchorPosition, setAtAnchorPosition] = useState<{ left: number; top: number } | null>(null)
|
||||||
|
const [atCommandValue, setAtCommandValue] = useState<string>('')
|
||||||
|
const atKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
|
||||||
|
const handleSelectAtMentionRef = useRef<(value: string) => void>(() => {})
|
||||||
|
|
||||||
// Keep ref in sync with state for the plugin to access
|
// Keep ref in sync with state for the plugin to access
|
||||||
selectionHighlightRef.current = selectionHighlight
|
selectionHighlightRef.current = selectionHighlight
|
||||||
|
|
@ -298,7 +341,70 @@ export function MarkdownEditor({
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPrimaryHeadingCommitRef.current = onPrimaryHeadingCommit
|
||||||
|
}, [onPrimaryHeadingCommit])
|
||||||
|
|
||||||
|
const maybeCommitPrimaryHeading = useCallback((view: EditorView) => {
|
||||||
|
const onCommit = onPrimaryHeadingCommitRef.current
|
||||||
|
if (!onCommit) return
|
||||||
|
const { selection, doc } = view.state
|
||||||
|
if (!selection.empty) return
|
||||||
|
|
||||||
|
const { $from } = selection
|
||||||
|
if ($from.depth < 1 || $from.index(0) !== 0) return
|
||||||
|
if (!['heading', 'paragraph'].includes($from.parent.type.name)) return
|
||||||
|
|
||||||
|
const firstNode = doc.firstChild
|
||||||
|
if (!firstNode || !['heading', 'paragraph'].includes(firstNode.type.name)) return
|
||||||
|
|
||||||
|
onCommit()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const preventTitleHeadingDemotion = useCallback((view: EditorView, event: KeyboardEvent) => {
|
||||||
|
if (!preserveUntitledTitleHeading) return false
|
||||||
|
if (event.key !== 'Backspace' || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return false
|
||||||
|
|
||||||
|
const { selection } = view.state
|
||||||
|
if (!selection.empty) return false
|
||||||
|
|
||||||
|
const { $from } = selection
|
||||||
|
if ($from.depth < 1 || $from.index(0) !== 0) return false
|
||||||
|
if ($from.parent.type.name !== 'heading') return false
|
||||||
|
|
||||||
|
const headingLevel = ((
|
||||||
|
$from.parent.attrs as { level?: number } | null | undefined
|
||||||
|
)?.level) ?? 0
|
||||||
|
if (headingLevel !== 1) return false
|
||||||
|
if ($from.parentOffset !== 0) return false
|
||||||
|
if ($from.parent.textContent.length > 0) return false
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
return true
|
||||||
|
}, [preserveUntitledTitleHeading])
|
||||||
|
|
||||||
|
const promoteFirstParagraphToTitleHeading = useCallback((view: EditorView) => {
|
||||||
|
if (!preserveUntitledTitleHeading) return
|
||||||
|
|
||||||
|
const { state, dispatch } = view
|
||||||
|
const { selection } = state
|
||||||
|
if (!selection.empty) return
|
||||||
|
|
||||||
|
const { $from } = selection
|
||||||
|
if ($from.depth < 1 || $from.index(0) !== 0) return
|
||||||
|
if ($from.parent.type.name !== 'paragraph') return
|
||||||
|
if ($from.parentOffset !== 0) return
|
||||||
|
if ($from.parent.textContent.length > 0) return
|
||||||
|
|
||||||
|
const headingType = state.schema.nodes.heading
|
||||||
|
if (!headingType) return
|
||||||
|
|
||||||
|
const tr = state.tr.setNodeMarkup($from.before(1), headingType, { level: 1 })
|
||||||
|
dispatch(tr)
|
||||||
|
}, [preserveUntitledTitleHeading])
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
|
editable,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: {
|
heading: {
|
||||||
|
|
@ -320,6 +426,7 @@ export function MarkdownEditor({
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
ImageUploadPlaceholderExtension,
|
ImageUploadPlaceholderExtension,
|
||||||
|
TaskBlockExtension,
|
||||||
WikiLink.configure({
|
WikiLink.configure({
|
||||||
onCreate: wikiLinks?.onCreate
|
onCreate: wikiLinks?.onCreate
|
||||||
? (path) => {
|
? (path) => {
|
||||||
|
|
@ -352,11 +459,14 @@ export function MarkdownEditor({
|
||||||
markdown = postprocessMarkdown(markdown)
|
markdown = postprocessMarkdown(markdown)
|
||||||
onChange(markdown)
|
onChange(markdown)
|
||||||
},
|
},
|
||||||
|
onBlur: ({ editor }) => {
|
||||||
|
maybeCommitPrimaryHeading(editor.view)
|
||||||
|
},
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class: 'prose prose-sm max-w-none focus:outline-none',
|
class: 'prose prose-sm max-w-none focus:outline-none',
|
||||||
},
|
},
|
||||||
handleKeyDown: (_view, event) => {
|
handleKeyDown: (view, event) => {
|
||||||
const state = wikiKeyStateRef.current
|
const state = wikiKeyStateRef.current
|
||||||
if (state.open) {
|
if (state.open) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
|
|
@ -389,9 +499,61 @@ export function MarkdownEditor({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ mention autocomplete keyboard handling
|
||||||
|
const atState = atKeyStateRef.current
|
||||||
|
if (atState.open) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
setActiveAtMention(null)
|
||||||
|
setAtAnchorPosition(null)
|
||||||
|
setAtCommandValue('')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||||
|
if (atState.options.length === 0) return true
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
const currentIndex = Math.max(0, atState.options.indexOf(atState.value))
|
||||||
|
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||||
|
const nextIndex = (currentIndex + delta + atState.options.length) % atState.options.length
|
||||||
|
setAtCommandValue(atState.options[nextIndex])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||||
|
if (atState.options.length === 0) return true
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
const selected = atState.options.includes(atState.value) ? atState.value : atState.options[0]
|
||||||
|
handleSelectAtMentionRef.current(selected)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preventTitleHeadingDemotion(view, event)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPrintableKey = event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey
|
||||||
|
if (isPrintableKey) {
|
||||||
|
promoteFirstParagraphToTitleHeading(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.key === 'Enter'
|
||||||
|
&& !event.shiftKey
|
||||||
|
&& !event.ctrlKey
|
||||||
|
&& !event.metaKey
|
||||||
|
&& !event.altKey
|
||||||
|
) {
|
||||||
|
maybeCommitPrimaryHeading(view)
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
handleClickOn: (_view, _pos, node, _nodePos, event) => {
|
handleClickOn: (_view, _pos, node, nodePos, event) => {
|
||||||
if (node.type.name === 'wikiLink') {
|
if (node.type.name === 'wikiLink') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
wikiLinks?.onOpen?.(node.attrs.path)
|
wikiLinks?.onOpen?.(node.attrs.path)
|
||||||
|
|
@ -400,7 +562,12 @@ export function MarkdownEditor({
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}, [
|
||||||
|
editorSessionKey,
|
||||||
|
maybeCommitPrimaryHeading,
|
||||||
|
preventTitleHeadingDemotion,
|
||||||
|
promoteFirstParagraphToTitleHeading,
|
||||||
|
])
|
||||||
|
|
||||||
const orderedFiles = useMemo(() => {
|
const orderedFiles = useMemo(() => {
|
||||||
if (!wikiLinks) return []
|
if (!wikiLinks) return []
|
||||||
|
|
@ -469,6 +636,118 @@ export function MarkdownEditor({
|
||||||
})
|
})
|
||||||
}, [editor, wikiLinks])
|
}, [editor, wikiLinks])
|
||||||
|
|
||||||
|
const updateRowboatMentionState = useCallback(() => {
|
||||||
|
if (!editor) return
|
||||||
|
const { selection } = editor.state
|
||||||
|
if (!selection.empty) {
|
||||||
|
setActiveRowboatMention(null)
|
||||||
|
setRowboatAnchorTop(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { $from } = selection
|
||||||
|
if ($from.parent.type.spec.code) {
|
||||||
|
setActiveRowboatMention(null)
|
||||||
|
setRowboatAnchorTop(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n')
|
||||||
|
const textBefore = text.slice(0, $from.parentOffset)
|
||||||
|
|
||||||
|
// Match @rowboat at a word boundary (preceded by nothing or whitespace)
|
||||||
|
const match = textBefore.match(/(^|\s)@rowboat$/)
|
||||||
|
if (!match) {
|
||||||
|
setActiveRowboatMention(null)
|
||||||
|
setRowboatAnchorTop(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerStart = textBefore.length - '@rowboat'.length
|
||||||
|
const from = selection.from - (textBefore.length - triggerStart)
|
||||||
|
const to = selection.from
|
||||||
|
setActiveRowboatMention({ range: { from, to } })
|
||||||
|
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper) {
|
||||||
|
setRowboatAnchorTop(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = editor.view.coordsAtPos(selection.from)
|
||||||
|
const wrapperRect = wrapper.getBoundingClientRect()
|
||||||
|
const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null
|
||||||
|
const pmRect = proseMirrorEl?.getBoundingClientRect()
|
||||||
|
setRowboatAnchorTop({
|
||||||
|
top: coords.top - wrapperRect.top + wrapper.scrollTop,
|
||||||
|
left: pmRect ? pmRect.left - wrapperRect.left : 0,
|
||||||
|
width: pmRect ? pmRect.width : wrapperRect.width,
|
||||||
|
})
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
// Detect @ trigger for autocomplete popover (similar to [[ detection)
|
||||||
|
const updateAtMentionState = useCallback(() => {
|
||||||
|
if (!editor) return
|
||||||
|
const { selection } = editor.state
|
||||||
|
if (!selection.empty) {
|
||||||
|
setActiveAtMention(null)
|
||||||
|
setAtAnchorPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { $from } = selection
|
||||||
|
// Skip code blocks
|
||||||
|
if ($from.parent.type.spec.code) {
|
||||||
|
setActiveAtMention(null)
|
||||||
|
setAtAnchorPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Skip inline code marks
|
||||||
|
if ($from.marks().some((mark) => mark.type.spec.code)) {
|
||||||
|
setActiveAtMention(null)
|
||||||
|
setAtAnchorPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n')
|
||||||
|
const textBefore = text.slice(0, $from.parentOffset)
|
||||||
|
|
||||||
|
// Find @ at a word boundary (start of line or preceded by whitespace)
|
||||||
|
const atMatch = textBefore.match(/(^|[\s])@([a-zA-Z0-9]*)$/)
|
||||||
|
if (!atMatch) {
|
||||||
|
setActiveAtMention(null)
|
||||||
|
setAtAnchorPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = atMatch[2] // text after @
|
||||||
|
|
||||||
|
// If the full "@rowboat" is already typed, let updateRowboatMentionState handle it
|
||||||
|
if (query === 'rowboat') {
|
||||||
|
setActiveAtMention(null)
|
||||||
|
setAtAnchorPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const atSymbolOffset = textBefore.lastIndexOf('@')
|
||||||
|
const matchText = textBefore.slice(atSymbolOffset)
|
||||||
|
const range = { from: selection.from - matchText.length, to: selection.from }
|
||||||
|
setActiveAtMention({ range, query })
|
||||||
|
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper) {
|
||||||
|
setAtAnchorPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = editor.view.coordsAtPos(selection.from)
|
||||||
|
const wrapperRect = wrapper.getBoundingClientRect()
|
||||||
|
setAtAnchorPosition({
|
||||||
|
left: coords.left - wrapperRect.left,
|
||||||
|
top: coords.bottom - wrapperRect.top,
|
||||||
|
})
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || !wikiLinks) return
|
if (!editor || !wikiLinks) return
|
||||||
editor.on('update', updateWikiLinkState)
|
editor.on('update', updateWikiLinkState)
|
||||||
|
|
@ -479,6 +758,42 @@ export function MarkdownEditor({
|
||||||
}
|
}
|
||||||
}, [editor, wikiLinks, updateWikiLinkState])
|
}, [editor, wikiLinks, updateWikiLinkState])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
editor.on('update', updateRowboatMentionState)
|
||||||
|
editor.on('selectionUpdate', updateRowboatMentionState)
|
||||||
|
return () => {
|
||||||
|
editor.off('update', updateRowboatMentionState)
|
||||||
|
editor.off('selectionUpdate', updateRowboatMentionState)
|
||||||
|
}
|
||||||
|
}, [editor, updateRowboatMentionState])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
editor.on('update', updateAtMentionState)
|
||||||
|
editor.on('selectionUpdate', updateAtMentionState)
|
||||||
|
return () => {
|
||||||
|
editor.off('update', updateAtMentionState)
|
||||||
|
editor.off('selectionUpdate', updateAtMentionState)
|
||||||
|
}
|
||||||
|
}, [editor, updateAtMentionState])
|
||||||
|
|
||||||
|
// When a tell-rowboat block is clicked, compute anchor and open popover
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rowboatBlockEdit || !editor) return
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper) return
|
||||||
|
const coords = editor.view.coordsAtPos(rowboatBlockEdit.nodePos)
|
||||||
|
const wrapperRect = wrapper.getBoundingClientRect()
|
||||||
|
const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null
|
||||||
|
const pmRect = proseMirrorEl?.getBoundingClientRect()
|
||||||
|
setRowboatAnchorTop({
|
||||||
|
top: coords.top - wrapperRect.top + wrapper.scrollTop,
|
||||||
|
left: pmRect ? pmRect.left - wrapperRect.left : 0,
|
||||||
|
width: pmRect ? pmRect.width : wrapperRect.width,
|
||||||
|
})
|
||||||
|
}, [editor, rowboatBlockEdit])
|
||||||
|
|
||||||
// Update editor content when prop changes (e.g., file selection changes)
|
// Update editor content when prop changes (e.g., file selection changes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editor && content !== undefined) {
|
if (editor && content !== undefined) {
|
||||||
|
|
@ -489,12 +804,37 @@ export function MarkdownEditor({
|
||||||
isInternalUpdate.current = true
|
isInternalUpdate.current = true
|
||||||
// Pre-process to preserve blank lines
|
// Pre-process to preserve blank lines
|
||||||
const preprocessed = preprocessMarkdown(content)
|
const preprocessed = preprocessMarkdown(content)
|
||||||
editor.commands.setContent(preprocessed)
|
// Treat tab-open content as baseline: do not add hydration to undo history.
|
||||||
|
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
|
||||||
isInternalUpdate.current = false
|
isInternalUpdate.current = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editor, content])
|
}, [editor, content])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onHistoryHandlersChange) return
|
||||||
|
if (!editor) {
|
||||||
|
onHistoryHandlersChange(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onHistoryHandlersChange({
|
||||||
|
undo: () => editor.chain().focus().undo().run(),
|
||||||
|
redo: () => editor.chain().focus().redo().run(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
onHistoryHandlersChange(null)
|
||||||
|
}
|
||||||
|
}, [editor, onHistoryHandlersChange])
|
||||||
|
|
||||||
|
// Update editable state when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor) {
|
||||||
|
editor.setEditable(editable)
|
||||||
|
}
|
||||||
|
}, [editor, editable])
|
||||||
|
|
||||||
// Force re-render decorations when selection highlight changes
|
// Force re-render decorations when selection highlight changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
|
|
@ -544,9 +884,89 @@ export function MarkdownEditor({
|
||||||
handleSelectWikiLinkRef.current = handleSelectWikiLink
|
handleSelectWikiLinkRef.current = handleSelectWikiLink
|
||||||
}, [handleSelectWikiLink])
|
}, [handleSelectWikiLink])
|
||||||
|
|
||||||
|
const handleRowboatAdd = useCallback(async (instruction: string) => {
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
if (rowboatBlockEdit) {
|
||||||
|
// Editing existing taskBlock — update its data attribute
|
||||||
|
const { nodePos } = rowboatBlockEdit
|
||||||
|
const node = editor.state.doc.nodeAt(nodePos)
|
||||||
|
if (node && node.type.name === 'taskBlock') {
|
||||||
|
// Preserve existing schedule data
|
||||||
|
let updated: Record<string, unknown> = { instruction }
|
||||||
|
try {
|
||||||
|
const existing = JSON.parse(node.attrs.data || '{}')
|
||||||
|
updated = { ...existing, instruction }
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON — just write new
|
||||||
|
}
|
||||||
|
const tr = editor.state.tr.setNodeMarkup(nodePos, undefined, { data: JSON.stringify(updated) })
|
||||||
|
editor.view.dispatch(tr)
|
||||||
|
}
|
||||||
|
setRowboatBlockEdit(null)
|
||||||
|
rowboatBlockEditRef.current = null
|
||||||
|
setRowboatAnchorTop(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeRowboatMention) {
|
||||||
|
// Classify schedule intent for new blocks
|
||||||
|
const blockData: Record<string, unknown> = { instruction }
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('inline-task:classifySchedule', { instruction })
|
||||||
|
if (result.schedule) {
|
||||||
|
const { label, ...rest } = result.schedule
|
||||||
|
blockData.schedule = rest
|
||||||
|
blockData['schedule-label'] = label
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RowboatAdd] Schedule classification failed:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContentAt(
|
||||||
|
{ from: activeRowboatMention.range.from, to: activeRowboatMention.range.to },
|
||||||
|
[
|
||||||
|
{ type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } },
|
||||||
|
{ type: 'paragraph' },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
// Mark note as live
|
||||||
|
if (onFrontmatterChange) {
|
||||||
|
const fields = extractAllFrontmatterValues(frontmatter ?? null)
|
||||||
|
fields['live_note'] = 'true'
|
||||||
|
onFrontmatterChange(buildFrontmatter(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveRowboatMention(null)
|
||||||
|
setRowboatAnchorTop(null)
|
||||||
|
}
|
||||||
|
}, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange])
|
||||||
|
|
||||||
|
const handleRowboatRemove = useCallback(() => {
|
||||||
|
if (!editor || !rowboatBlockEdit) return
|
||||||
|
const { nodePos } = rowboatBlockEdit
|
||||||
|
const node = editor.state.doc.nodeAt(nodePos)
|
||||||
|
if (node) {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange({ from: nodePos, to: nodePos + node.nodeSize })
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
setRowboatBlockEdit(null)
|
||||||
|
rowboatBlockEditRef.current = null
|
||||||
|
setRowboatAnchorTop(null)
|
||||||
|
}, [editor, rowboatBlockEdit])
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
updateWikiLinkState()
|
updateWikiLinkState()
|
||||||
}, [updateWikiLinkState])
|
updateAtMentionState()
|
||||||
|
}, [updateWikiLinkState, updateAtMentionState])
|
||||||
|
|
||||||
const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition)
|
const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition)
|
||||||
const wikiOptions = useMemo(() => {
|
const wikiOptions = useMemo(() => {
|
||||||
|
|
@ -574,6 +994,63 @@ export function MarkdownEditor({
|
||||||
setWikiCommandValue((prev) => (wikiOptions.includes(prev) ? prev : wikiOptions[0]))
|
setWikiCommandValue((prev) => (wikiOptions.includes(prev) ? prev : wikiOptions[0]))
|
||||||
}, [showWikiPopover, wikiOptions])
|
}, [showWikiPopover, wikiOptions])
|
||||||
|
|
||||||
|
// @ mention autocomplete options
|
||||||
|
const atMentionOptions = useMemo(() => [
|
||||||
|
{ value: 'rowboat', label: '@rowboat', description: 'Research, schedule, or run tasks with AI' },
|
||||||
|
], [])
|
||||||
|
|
||||||
|
const filteredAtOptions = useMemo(() => {
|
||||||
|
if (!activeAtMention) return []
|
||||||
|
const q = activeAtMention.query.toLowerCase()
|
||||||
|
if (!q) return atMentionOptions
|
||||||
|
return atMentionOptions.filter((opt) => opt.value.toLowerCase().startsWith(q))
|
||||||
|
}, [activeAtMention, atMentionOptions])
|
||||||
|
|
||||||
|
const atOptionValues = useMemo(() => filteredAtOptions.map((o) => o.value), [filteredAtOptions])
|
||||||
|
const showAtPopover = Boolean(activeAtMention && atAnchorPosition && filteredAtOptions.length > 0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
atKeyStateRef.current = { open: showAtPopover, options: atOptionValues, value: atCommandValue }
|
||||||
|
}, [showAtPopover, atOptionValues, atCommandValue])
|
||||||
|
|
||||||
|
// Keep @ cmdk selection in sync
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showAtPopover) {
|
||||||
|
setAtCommandValue('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (atOptionValues.length === 0) {
|
||||||
|
setAtCommandValue('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAtCommandValue((prev) => (atOptionValues.includes(prev) ? prev : atOptionValues[0]))
|
||||||
|
}, [showAtPopover, atOptionValues])
|
||||||
|
|
||||||
|
// @ mention selection handler
|
||||||
|
const handleSelectAtMention = useCallback((value: string) => {
|
||||||
|
if (!editor || !activeAtMention) return
|
||||||
|
|
||||||
|
if (value === 'rowboat') {
|
||||||
|
// Replace "@<partial>" with "@rowboat" — this triggers updateRowboatMentionState
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContentAt(
|
||||||
|
{ from: activeAtMention.range.from, to: activeAtMention.range.to },
|
||||||
|
'@rowboat'
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveAtMention(null)
|
||||||
|
setAtAnchorPosition(null)
|
||||||
|
setAtCommandValue('')
|
||||||
|
}, [editor, activeAtMention])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleSelectAtMentionRef.current = handleSelectAtMention
|
||||||
|
}, [handleSelectAtMention])
|
||||||
|
|
||||||
// Handle keyboard shortcuts
|
// Handle keyboard shortcuts
|
||||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||||
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
|
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
|
||||||
|
|
@ -595,6 +1072,13 @@ export function MarkdownEditor({
|
||||||
onSelectionHighlight={setSelectionHighlight}
|
onSelectionHighlight={setSelectionHighlight}
|
||||||
onImageUpload={handleImageUploadWithPlaceholder}
|
onImageUpload={handleImageUploadWithPlaceholder}
|
||||||
/>
|
/>
|
||||||
|
{(frontmatter !== undefined) && onFrontmatterChange && (
|
||||||
|
<FrontmatterProperties
|
||||||
|
raw={frontmatter}
|
||||||
|
onRawChange={onFrontmatterChange}
|
||||||
|
editable={editable}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
{wikiLinks ? (
|
{wikiLinks ? (
|
||||||
|
|
@ -651,6 +1135,64 @@ export function MarkdownEditor({
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
) : null}
|
) : null}
|
||||||
|
{/* @ mention autocomplete popover */}
|
||||||
|
<Popover
|
||||||
|
open={showAtPopover}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setActiveAtMention(null)
|
||||||
|
setAtAnchorPosition(null)
|
||||||
|
setAtCommandValue('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverAnchor asChild>
|
||||||
|
<span
|
||||||
|
className="wiki-link-anchor"
|
||||||
|
style={
|
||||||
|
atAnchorPosition
|
||||||
|
? { left: atAnchorPosition.left, top: atAnchorPosition.top }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PopoverAnchor>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-72 p-1"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<Command shouldFilter={false} value={atCommandValue} onValueChange={setAtCommandValue}>
|
||||||
|
<CommandList>
|
||||||
|
{filteredAtOptions.map((opt) => (
|
||||||
|
<CommandItem
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.value}
|
||||||
|
onSelect={() => handleSelectAtMention(opt.value)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{opt.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{opt.description}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<RowboatMentionPopover
|
||||||
|
open={Boolean((activeRowboatMention || rowboatBlockEdit) && rowboatAnchorTop)}
|
||||||
|
anchor={rowboatAnchorTop}
|
||||||
|
initialText={rowboatBlockEdit?.existingText ?? ''}
|
||||||
|
onAdd={handleRowboatAdd}
|
||||||
|
onRemove={rowboatBlockEdit ? handleRowboatRemove : undefined}
|
||||||
|
onClose={() => {
|
||||||
|
setActiveRowboatMention(null)
|
||||||
|
setRowboatBlockEdit(null)
|
||||||
|
rowboatBlockEditRef.current = null
|
||||||
|
setRowboatAnchorTop(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -55,14 +55,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||||
const [modelsLoading, setModelsLoading] = useState(false)
|
const [modelsLoading, setModelsLoading] = useState(false)
|
||||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({
|
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
|
||||||
openai: { apiKey: "", baseURL: "", model: "" },
|
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
anthropic: { apiKey: "", baseURL: "", model: "" },
|
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
google: { apiKey: "", baseURL: "", model: "" },
|
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
openrouter: { apiKey: "", baseURL: "", model: "" },
|
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
aigateway: { apiKey: "", baseURL: "", model: "" },
|
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" },
|
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
|
||||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" },
|
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
|
||||||
})
|
})
|
||||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||||
status: "idle",
|
status: "idle",
|
||||||
|
|
@ -89,7 +89,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||||
|
|
||||||
const updateProviderConfig = useCallback(
|
const updateProviderConfig = useCallback(
|
||||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => {
|
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[provider]: { ...prev[provider], ...updates },
|
[provider]: { ...prev[provider], ...updates },
|
||||||
|
|
@ -306,6 +306,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
const apiKey = activeConfig.apiKey.trim() || undefined
|
const apiKey = activeConfig.apiKey.trim() || undefined
|
||||||
const baseURL = activeConfig.baseURL.trim() || undefined
|
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||||
const model = activeConfig.model.trim()
|
const model = activeConfig.model.trim()
|
||||||
|
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||||
const providerConfig = {
|
const providerConfig = {
|
||||||
provider: {
|
provider: {
|
||||||
flavor: llmProvider,
|
flavor: llmProvider,
|
||||||
|
|
@ -313,6 +314,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
baseURL,
|
baseURL,
|
||||||
},
|
},
|
||||||
model,
|
model,
|
||||||
|
knowledgeGraphModel,
|
||||||
}
|
}
|
||||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
@ -691,39 +693,74 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
|
<div className="space-y-2">
|
||||||
{modelsLoading ? (
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
{modelsLoading ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
Loading models...
|
<Loader2 className="size-4 animate-spin" />
|
||||||
</div>
|
Loading...
|
||||||
) : showModelInput ? (
|
</div>
|
||||||
<Input
|
) : showModelInput ? (
|
||||||
value={activeConfig.model}
|
<Input
|
||||||
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
value={activeConfig.model}
|
||||||
placeholder="Enter model"
|
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
||||||
/>
|
placeholder="Enter model"
|
||||||
) : (
|
/>
|
||||||
<Select
|
) : (
|
||||||
value={activeConfig.model}
|
<Select
|
||||||
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
|
value={activeConfig.model}
|
||||||
>
|
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
|
||||||
<SelectTrigger>
|
>
|
||||||
<SelectValue placeholder="Select a model" />
|
<SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Select a model" />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
{modelsForProvider.map((model) => (
|
<SelectContent>
|
||||||
<SelectItem key={model.id} value={model.id}>
|
{modelsForProvider.map((model) => (
|
||||||
{model.name || model.id}
|
<SelectItem key={model.id} value={model.id}>
|
||||||
</SelectItem>
|
{model.name || model.id}
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
)}
|
</Select>
|
||||||
{modelsError && (
|
)}
|
||||||
<div className="text-xs text-destructive">{modelsError}</div>
|
{modelsError && (
|
||||||
)}
|
<div className="text-xs text-destructive">{modelsError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
|
||||||
|
{modelsLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : showModelInput ? (
|
||||||
|
<Input
|
||||||
|
value={activeConfig.knowledgeGraphModel}
|
||||||
|
onChange={(e) => updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
|
||||||
|
placeholder={activeConfig.model || "Enter model"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={activeConfig.knowledgeGraphModel || "__same__"}
|
||||||
|
onValueChange={(value) => updateProviderConfig(llmProvider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||||
|
{modelsForProvider.map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.id}>
|
||||||
|
{model.name || model.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showApiKey && (
|
{showApiKey && (
|
||||||
|
|
|
||||||
109
apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx
Normal file
109
apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
interface RowboatMentionPopoverProps {
|
||||||
|
open: boolean
|
||||||
|
anchor: { top: number; left: number; width: number } | null
|
||||||
|
initialText?: string
|
||||||
|
onAdd: (instruction: string) => void | Promise<void>
|
||||||
|
onRemove?: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RowboatMentionPopover({ open, anchor, initialText = '', onAdd, onRemove, onClose }: RowboatMentionPopoverProps) {
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setText(initialText)
|
||||||
|
setLoading(false)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [open, initialText])
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleMouseDown)
|
||||||
|
return () => document.removeEventListener('mousedown', handleMouseDown)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open || !anchor) return null
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (!trimmed || loading) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await onAdd(trimmed)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
setText('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="absolute z-50"
|
||||||
|
style={{
|
||||||
|
top: anchor.top,
|
||||||
|
left: anchor.left,
|
||||||
|
width: anchor.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative border border-input rounded-md bg-popover shadow-sm">
|
||||||
|
<div className="flex items-start gap-1.5 px-3 pt-2 pb-8">
|
||||||
|
<span className="text-sm text-muted-foreground select-none shrink-0 leading-[1.5]">@rowboat</span>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none resize-none leading-[1.5]"
|
||||||
|
placeholder=""
|
||||||
|
rows={2}
|
||||||
|
value={text}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey || e.shiftKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
void handleSubmit()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1.5">
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded px-2.5 py-1 text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
disabled={!text.trim() || loading}
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="size-3 animate-spin" /> : 'Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2 } from "lucide-react"
|
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X } from "lucide-react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -18,11 +18,12 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useTheme } from "@/contexts/theme-context"
|
import { useTheme } from "@/contexts/theme-context"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
type ConfigTab = "models" | "mcp" | "security" | "appearance"
|
type ConfigTab = "models" | "mcp" | "security" | "appearance" | "note-tagging"
|
||||||
|
|
||||||
interface TabConfig {
|
interface TabConfig {
|
||||||
id: ConfigTab
|
id: ConfigTab
|
||||||
|
|
@ -60,6 +61,13 @@ const tabs: TabConfig[] = [
|
||||||
icon: Palette,
|
icon: Palette,
|
||||||
description: "Customize the look and feel",
|
description: "Customize the look and feel",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "note-tagging",
|
||||||
|
label: "Note Tagging",
|
||||||
|
icon: Tags,
|
||||||
|
path: "config/tags.json",
|
||||||
|
description: "Configure tags for notes and emails",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
|
|
@ -167,14 +175,15 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
||||||
|
|
||||||
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({
|
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
||||||
openai: { apiKey: "", baseURL: "", model: "" },
|
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>>({
|
||||||
anthropic: { apiKey: "", baseURL: "", model: "" },
|
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||||
google: { apiKey: "", baseURL: "", model: "" },
|
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||||
openrouter: { apiKey: "", baseURL: "", model: "" },
|
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||||
aigateway: { apiKey: "", baseURL: "", model: "" },
|
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" },
|
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" },
|
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "" },
|
||||||
|
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "" },
|
||||||
})
|
})
|
||||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||||
const [modelsLoading, setModelsLoading] = useState(false)
|
const [modelsLoading, setModelsLoading] = useState(false)
|
||||||
|
|
@ -193,13 +202,14 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
const showModelInput = isLocalProvider || modelsForProvider.length === 0
|
const showModelInput = isLocalProvider || modelsForProvider.length === 0
|
||||||
const isMoreProvider = moreProviders.some(p => p.id === provider)
|
const isMoreProvider = moreProviders.some(p => p.id === provider)
|
||||||
|
|
||||||
|
const primaryModel = activeConfig.models[0] || ""
|
||||||
const canTest =
|
const canTest =
|
||||||
activeConfig.model.trim().length > 0 &&
|
primaryModel.trim().length > 0 &&
|
||||||
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
|
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
|
||||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||||
|
|
||||||
const updateConfig = useCallback(
|
const updateConfig = useCallback(
|
||||||
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => {
|
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>) => {
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[prov]: { ...prev[prov], ...updates },
|
[prov]: { ...prev[prov], ...updates },
|
||||||
|
|
@ -209,6 +219,39 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const updateModelAt = useCallback(
|
||||||
|
(prov: LlmProviderFlavor, index: number, value: string) => {
|
||||||
|
setProviderConfigs(prev => {
|
||||||
|
const models = [...prev[prov].models]
|
||||||
|
models[index] = value
|
||||||
|
return { ...prev, [prov]: { ...prev[prov], models } }
|
||||||
|
})
|
||||||
|
setTestState({ status: "idle" })
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const addModel = useCallback(
|
||||||
|
(prov: LlmProviderFlavor) => {
|
||||||
|
setProviderConfigs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[prov]: { ...prev[prov], models: [...prev[prov].models, ""] },
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeModel = useCallback(
|
||||||
|
(prov: LlmProviderFlavor, index: number) => {
|
||||||
|
setProviderConfigs(prev => {
|
||||||
|
const models = prev[prov].models.filter((_, i) => i !== index)
|
||||||
|
return { ...prev, [prov]: { ...prev[prov], models: models.length > 0 ? models : [""] } }
|
||||||
|
})
|
||||||
|
setTestState({ status: "idle" })
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
// Load current config from file
|
// Load current config from file
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dialogOpen) return
|
if (!dialogOpen) return
|
||||||
|
|
@ -223,14 +266,42 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
if (parsed?.provider?.flavor && parsed?.model) {
|
if (parsed?.provider?.flavor && parsed?.model) {
|
||||||
const flavor = parsed.provider.flavor as LlmProviderFlavor
|
const flavor = parsed.provider.flavor as LlmProviderFlavor
|
||||||
setProvider(flavor)
|
setProvider(flavor)
|
||||||
setProviderConfigs(prev => ({
|
setDefaultProvider(flavor)
|
||||||
...prev,
|
setProviderConfigs(prev => {
|
||||||
[flavor]: {
|
const next = { ...prev };
|
||||||
apiKey: parsed.provider.apiKey || "",
|
// Hydrate all saved providers from the providers map
|
||||||
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
|
if (parsed.providers) {
|
||||||
model: parsed.model,
|
for (const [key, entry] of Object.entries(parsed.providers)) {
|
||||||
},
|
if (key in next) {
|
||||||
}))
|
const e = entry as any;
|
||||||
|
const savedModels: string[] = Array.isArray(e.models) && e.models.length > 0
|
||||||
|
? e.models
|
||||||
|
: e.model ? [e.model] : [""];
|
||||||
|
next[key as LlmProviderFlavor] = {
|
||||||
|
apiKey: e.apiKey || "",
|
||||||
|
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
|
||||||
|
models: savedModels,
|
||||||
|
knowledgeGraphModel: e.knowledgeGraphModel || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Active provider takes precedence from top-level config,
|
||||||
|
// but only if it exists in the providers map (wasn't deleted)
|
||||||
|
if (parsed.providers?.[flavor]) {
|
||||||
|
const existingModels = next[flavor].models;
|
||||||
|
const activeModels = existingModels[0] === parsed.model
|
||||||
|
? existingModels
|
||||||
|
: [parsed.model, ...existingModels.filter((m: string) => m && m !== parsed.model)];
|
||||||
|
next[flavor] = {
|
||||||
|
apiKey: parsed.provider.apiKey || "",
|
||||||
|
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
|
||||||
|
models: activeModels.length > 0 ? activeModels : [""],
|
||||||
|
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// No existing config or parse error - use defaults
|
// No existing config or parse error - use defaults
|
||||||
|
|
@ -274,11 +345,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
||||||
for (const prov of cloudProviders) {
|
for (const prov of cloudProviders) {
|
||||||
const models = modelsCatalog[prov]
|
const catalog = modelsCatalog[prov]
|
||||||
if (models?.length && !next[prov].model) {
|
if (catalog?.length && !next[prov].models[0]) {
|
||||||
const preferred = preferredDefaults[prov]
|
const preferred = preferredDefaults[prov]
|
||||||
const hasPreferred = preferred && models.some(m => m.id === preferred)
|
const hasPreferred = preferred && catalog.some(m => m.id === preferred)
|
||||||
next[prov] = { ...next[prov], model: hasPreferred ? preferred : (models[0]?.id || "") }
|
const defaultModel = hasPreferred ? preferred! : (catalog[0]?.id || "")
|
||||||
|
next[prov] = { ...next[prov], models: [defaultModel] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
|
|
@ -289,18 +361,23 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
if (!canTest) return
|
if (!canTest) return
|
||||||
setTestState({ status: "testing" })
|
setTestState({ status: "testing" })
|
||||||
try {
|
try {
|
||||||
|
const allModels = activeConfig.models.map(m => m.trim()).filter(Boolean)
|
||||||
const providerConfig = {
|
const providerConfig = {
|
||||||
provider: {
|
provider: {
|
||||||
flavor: provider,
|
flavor: provider,
|
||||||
apiKey: activeConfig.apiKey.trim() || undefined,
|
apiKey: activeConfig.apiKey.trim() || undefined,
|
||||||
baseURL: activeConfig.baseURL.trim() || undefined,
|
baseURL: activeConfig.baseURL.trim() || undefined,
|
||||||
},
|
},
|
||||||
model: activeConfig.model.trim(),
|
model: allModels[0] || "",
|
||||||
|
models: allModels,
|
||||||
|
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
||||||
}
|
}
|
||||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await window.ipc.invoke("models:saveConfig", providerConfig)
|
await window.ipc.invoke("models:saveConfig", providerConfig)
|
||||||
|
setDefaultProvider(provider)
|
||||||
setTestState({ status: "success" })
|
setTestState({ status: "success" })
|
||||||
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
toast.success("Model configuration saved")
|
toast.success("Model configuration saved")
|
||||||
} else {
|
} else {
|
||||||
setTestState({ status: "error", error: result.error })
|
setTestState({ status: "error", error: result.error })
|
||||||
|
|
@ -312,24 +389,120 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
}
|
}
|
||||||
}, [canTest, provider, activeConfig])
|
}, [canTest, provider, activeConfig])
|
||||||
|
|
||||||
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => (
|
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
|
||||||
<button
|
const config = providerConfigs[prov]
|
||||||
key={p.id}
|
const allModels = config.models.map(m => m.trim()).filter(Boolean)
|
||||||
onClick={() => {
|
if (!allModels[0]) return
|
||||||
setProvider(p.id)
|
try {
|
||||||
setTestState({ status: "idle" })
|
await window.ipc.invoke("models:saveConfig", {
|
||||||
}}
|
provider: {
|
||||||
className={cn(
|
flavor: prov,
|
||||||
"rounded-md border px-3 py-2.5 text-left transition-colors",
|
apiKey: config.apiKey.trim() || undefined,
|
||||||
provider === p.id
|
baseURL: config.baseURL.trim() || undefined,
|
||||||
? "border-primary bg-primary/5"
|
},
|
||||||
: "border-border hover:bg-accent"
|
model: allModels[0],
|
||||||
)}
|
models: allModels,
|
||||||
>
|
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
||||||
<div className="text-sm font-medium">{p.name}</div>
|
})
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
|
setDefaultProvider(prov)
|
||||||
</button>
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
)
|
toast.success("Default provider updated")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to set default provider")
|
||||||
|
}
|
||||||
|
}, [providerConfigs])
|
||||||
|
|
||||||
|
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
||||||
|
const parsed = JSON.parse(result.data)
|
||||||
|
if (parsed?.providers?.[prov]) {
|
||||||
|
delete parsed.providers[prov]
|
||||||
|
}
|
||||||
|
// If the deleted provider is the current top-level active one,
|
||||||
|
// switch top-level config to the current default provider
|
||||||
|
if (parsed?.provider?.flavor === prov && defaultProvider && defaultProvider !== prov) {
|
||||||
|
const defConfig = providerConfigs[defaultProvider]
|
||||||
|
const defModels = defConfig.models.map(m => m.trim()).filter(Boolean)
|
||||||
|
parsed.provider = {
|
||||||
|
flavor: defaultProvider,
|
||||||
|
apiKey: defConfig.apiKey.trim() || undefined,
|
||||||
|
baseURL: defConfig.baseURL.trim() || undefined,
|
||||||
|
}
|
||||||
|
parsed.model = defModels[0] || ""
|
||||||
|
parsed.models = defModels
|
||||||
|
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
||||||
|
}
|
||||||
|
await window.ipc.invoke("workspace:writeFile", {
|
||||||
|
path: "config/models.json",
|
||||||
|
data: JSON.stringify(parsed, null, 2),
|
||||||
|
})
|
||||||
|
setProviderConfigs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "" },
|
||||||
|
}))
|
||||||
|
setTestState({ status: "idle" })
|
||||||
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
|
toast.success("Provider configuration removed")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to remove provider")
|
||||||
|
}
|
||||||
|
}, [defaultProvider, providerConfigs])
|
||||||
|
|
||||||
|
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => {
|
||||||
|
const isDefault = defaultProvider === p.id
|
||||||
|
const isSelected = provider === p.id
|
||||||
|
const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => {
|
||||||
|
setProvider(p.id)
|
||||||
|
setTestState({ status: "idle" })
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border px-3 py-2.5 text-left transition-colors relative",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-medium">{p.name}</span>
|
||||||
|
{isDefault && (
|
||||||
|
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
|
||||||
|
{!isDefault && hasModel && isSelected && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleSetDefault(p.id)
|
||||||
|
}}
|
||||||
|
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Set as default
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteProvider(p.id)
|
||||||
|
}}
|
||||||
|
className="inline-flex text-[11px] text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (configLoading) {
|
if (configLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -362,40 +535,100 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model selection */}
|
{/* Model selection - side by side */}
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
|
{/* Assistant models (left column) */}
|
||||||
{modelsLoading ? (
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
{modelsLoading ? (
|
||||||
Loading models...
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
</div>
|
<Loader2 className="size-4 animate-spin" />
|
||||||
) : showModelInput ? (
|
Loading...
|
||||||
<Input
|
</div>
|
||||||
value={activeConfig.model}
|
) : (
|
||||||
onChange={(e) => updateConfig(provider, { model: e.target.value })}
|
<div className="space-y-2">
|
||||||
placeholder="Enter model"
|
{activeConfig.models.map((model, index) => (
|
||||||
/>
|
<div key={index} className="group/model relative">
|
||||||
) : (
|
{showModelInput ? (
|
||||||
<Select
|
<Input
|
||||||
value={activeConfig.model}
|
value={model}
|
||||||
onValueChange={(value) => updateConfig(provider, { model: value })}
|
onChange={(e) => updateModelAt(provider, index, e.target.value)}
|
||||||
>
|
placeholder="Enter model"
|
||||||
<SelectTrigger>
|
/>
|
||||||
<SelectValue placeholder="Select a model" />
|
) : (
|
||||||
</SelectTrigger>
|
<Select
|
||||||
<SelectContent>
|
value={model}
|
||||||
{modelsForProvider.map((model) => (
|
onValueChange={(value) => updateModelAt(provider, index, value)}
|
||||||
<SelectItem key={model.id} value={model.id}>
|
>
|
||||||
{model.name || model.id}
|
<SelectTrigger>
|
||||||
</SelectItem>
|
<SelectValue placeholder="Select a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{modelsForProvider.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id}>
|
||||||
|
{m.name || m.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{activeConfig.models.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeModel(provider, index)}
|
||||||
|
className="absolute right-8 top-1/2 -translate-y-1/2 flex size-6 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/model:opacity-100"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
<button
|
||||||
</Select>
|
onClick={() => addModel(provider)}
|
||||||
)}
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
{modelsError && (
|
>
|
||||||
<div className="text-xs text-destructive">{modelsError}</div>
|
<Plus className="size-3.5" />
|
||||||
)}
|
Add assistant model
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{modelsError && (
|
||||||
|
<div className="text-xs text-destructive">{modelsError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Knowledge graph model (right column) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
|
||||||
|
{modelsLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : showModelInput ? (
|
||||||
|
<Input
|
||||||
|
value={activeConfig.knowledgeGraphModel}
|
||||||
|
onChange={(e) => updateConfig(provider, { knowledgeGraphModel: e.target.value })}
|
||||||
|
placeholder={primaryModel || "Enter model"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={activeConfig.knowledgeGraphModel || "__same__"}
|
||||||
|
onValueChange={(value) => updateConfig(provider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||||
|
{modelsForProvider.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id}>
|
||||||
|
{m.name || m.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Key */}
|
{/* API Key */}
|
||||||
|
|
@ -460,6 +693,415 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Note Tagging Settings ---
|
||||||
|
|
||||||
|
interface TagDef {
|
||||||
|
tag: string
|
||||||
|
type: string
|
||||||
|
applicability: "email" | "notes" | "both"
|
||||||
|
description: string
|
||||||
|
example?: string
|
||||||
|
noteEffect?: "create" | "skip" | "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTE_TAG_TYPE_ORDER = [
|
||||||
|
"relationship", "relationship-sub", "topic", "action", "status", "source",
|
||||||
|
]
|
||||||
|
|
||||||
|
const EMAIL_TAG_TYPE_ORDER = [
|
||||||
|
"relationship", "topic", "email-type", "filter", "action", "status",
|
||||||
|
]
|
||||||
|
|
||||||
|
const TAG_TYPE_LABELS: Record<string, string> = {
|
||||||
|
"relationship": "Relationship",
|
||||||
|
"relationship-sub": "Relationship Sub-Tags",
|
||||||
|
"topic": "Topic",
|
||||||
|
"email-type": "Email Type",
|
||||||
|
"filter": "Filter",
|
||||||
|
"action": "Action",
|
||||||
|
"status": "Status",
|
||||||
|
"source": "Source",
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TAGS: TagDef[] = [
|
||||||
|
{ tag: "investor", type: "relationship", applicability: "both", noteEffect: "create", description: "Investors, VCs, or angels", example: "Following up on our meeting — we'd like to move forward with the Series A term sheet." },
|
||||||
|
{ tag: "customer", type: "relationship", applicability: "both", noteEffect: "create", description: "Paying customers", example: "We're seeing great results with Rowboat. Can we discuss expanding to more teams?" },
|
||||||
|
{ tag: "prospect", type: "relationship", applicability: "both", noteEffect: "create", description: "Potential customers", example: "Thanks for the demo yesterday. We're interested in starting a pilot." },
|
||||||
|
{ tag: "partner", type: "relationship", applicability: "both", noteEffect: "create", description: "Business partners", example: "Let's discuss how we can promote the integration to both our user bases." },
|
||||||
|
{ tag: "vendor", type: "relationship", applicability: "both", noteEffect: "create", description: "Service providers you work with", example: "Here are the updated employment agreements you requested." },
|
||||||
|
{ tag: "product", type: "relationship", applicability: "both", noteEffect: "skip", description: "Products or services you use (automated)", example: "Your AWS bill for January 2025 is now available." },
|
||||||
|
{ tag: "candidate", type: "relationship", applicability: "both", noteEffect: "create", description: "Job applicants", example: "Thanks for reaching out. I'd love to learn more about the engineering role." },
|
||||||
|
{ tag: "team", type: "relationship", applicability: "both", noteEffect: "create", description: "Internal team members", example: "Here's the updated roadmap for Q2. Let's discuss in our sync." },
|
||||||
|
{ tag: "advisor", type: "relationship", applicability: "both", noteEffect: "create", description: "Advisors, mentors, or board members", example: "I've reviewed the deck. Here are my thoughts on the GTM strategy." },
|
||||||
|
{ tag: "personal", type: "relationship", applicability: "both", noteEffect: "create", description: "Family or friends", example: "Are you coming to Thanksgiving this year? Let me know your travel dates." },
|
||||||
|
{ tag: "press", type: "relationship", applicability: "both", noteEffect: "create", description: "Journalists or media", example: "I'm writing a piece on AI agents. Would you be available for an interview?" },
|
||||||
|
{ tag: "community", type: "relationship", applicability: "both", noteEffect: "create", description: "Users, peers, or open source contributors", example: "Love what you're building with Rowboat. Here's a bug I found..." },
|
||||||
|
{ tag: "government", type: "relationship", applicability: "both", noteEffect: "create", description: "Government agencies", example: "Your Delaware franchise tax is due by March 1, 2025." },
|
||||||
|
{ tag: "primary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Main contact or decision maker", example: "Sarah Chen — VP Engineering, your main point of contact at Acme." },
|
||||||
|
{ tag: "secondary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Supporting contact, involved but not the lead", example: "David Kim — Engineer CC'd on customer emails." },
|
||||||
|
{ tag: "executive-assistant", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "EA or admin handling scheduling and logistics", example: "Lisa — Sarah's EA who schedules all her meetings." },
|
||||||
|
{ tag: "cc", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who's CC'd but not actively engaged", example: "Manager looped in for visibility on deal." },
|
||||||
|
{ tag: "referred-by", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who made an introduction or referral", example: "David Park — Investor who intro'd you to Sarah." },
|
||||||
|
{ tag: "former", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Previously held this relationship, no longer active", example: "John — Former customer who churned last year." },
|
||||||
|
{ tag: "champion", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Internal advocate pushing for you", example: "Engineer who loves your product and is selling internally." },
|
||||||
|
{ tag: "blocker", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person opposing or blocking progress", example: "CFO resistant to spending on new tools." },
|
||||||
|
{ tag: "sales", type: "topic", applicability: "both", noteEffect: "create", description: "Sales conversations, deals, and revenue", example: "Here's the pricing proposal we discussed. Let me know if you have questions." },
|
||||||
|
{ tag: "support", type: "topic", applicability: "both", noteEffect: "create", description: "Help requests, issues, and customer support", example: "We're seeing an error when trying to export. Can you help?" },
|
||||||
|
{ tag: "legal", type: "topic", applicability: "both", noteEffect: "create", description: "Contracts, terms, compliance, and legal matters", example: "Legal has reviewed the MSA. Attached are our requested changes." },
|
||||||
|
{ tag: "finance", type: "topic", applicability: "both", noteEffect: "create", description: "Money, invoices, payments, banking, and taxes", example: "Your invoice #1234 for $5,000 is attached. Payment due in 30 days." },
|
||||||
|
{ tag: "hiring", type: "topic", applicability: "both", noteEffect: "create", description: "Recruiting, interviews, and employment", example: "We'd like to move forward with a final round interview. Are you available Thursday?" },
|
||||||
|
{ tag: "fundraising", type: "topic", applicability: "both", noteEffect: "create", description: "Raising money and investor relations", example: "Thanks for sending the deck. We'd like to schedule a partner meeting." },
|
||||||
|
{ tag: "travel", type: "topic", applicability: "both", noteEffect: "skip", description: "Flights, hotels, trips, and travel logistics", example: "Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123." },
|
||||||
|
{ tag: "event", type: "topic", applicability: "both", noteEffect: "create", description: "Conferences, meetups, and gatherings", example: "You're invited to speak at TechCrunch Disrupt. Can you confirm your availability?" },
|
||||||
|
{ tag: "shopping", type: "topic", applicability: "both", noteEffect: "skip", description: "Purchases, orders, and returns", example: "Your order #12345 has shipped. Track it here." },
|
||||||
|
{ tag: "health", type: "topic", applicability: "both", noteEffect: "skip", description: "Medical, wellness, and health-related matters", example: "Your appointment with Dr. Smith is confirmed for Monday at 2pm." },
|
||||||
|
{ tag: "learning", type: "topic", applicability: "both", noteEffect: "skip", description: "Courses, education, and skill-building", example: "Welcome to the Advanced Python course. Here's your access link." },
|
||||||
|
{ tag: "research", type: "topic", applicability: "both", noteEffect: "create", description: "Research requests and information gathering", example: "Here's the market analysis you requested on the AI agent space." },
|
||||||
|
{ tag: "intro", type: "email-type", applicability: "both", noteEffect: "create", description: "Warm introduction from someone you know", example: "I'd like to introduce you to Sarah Chen, VP Engineering at Acme." },
|
||||||
|
{ tag: "followup", type: "email-type", applicability: "both", noteEffect: "create", description: "Following up on a previous conversation", example: "Following up on our call last week. Have you had a chance to review the proposal?" },
|
||||||
|
{ tag: "scheduling", type: "email-type", applicability: "email", noteEffect: "skip", description: "Meeting and calendar scheduling", example: "Are you available for a call next Tuesday at 2pm?" },
|
||||||
|
{ tag: "cold-outreach", type: "email-type", applicability: "email", noteEffect: "skip", description: "Unsolicited contact from someone you don't know", example: "Hi, I noticed your company is growing fast. I'd love to show you how we can help with..." },
|
||||||
|
{ tag: "newsletter", type: "email-type", applicability: "email", noteEffect: "skip", description: "Newsletters, marketing emails, and subscriptions", example: "This week in AI: The latest developments in agent frameworks..." },
|
||||||
|
{ tag: "notification", type: "email-type", applicability: "email", noteEffect: "skip", description: "Automated alerts, receipts, and system notifications", example: "Your password was changed successfully. If this wasn't you, contact support." },
|
||||||
|
{ tag: "spam", type: "filter", applicability: "email", noteEffect: "skip", description: "Junk and unwanted email", example: "Congratulations! You've won $1,000,000..." },
|
||||||
|
{ tag: "promotion", type: "filter", applicability: "email", noteEffect: "skip", description: "Marketing offers and sales pitches", example: "50% off all items this weekend only!" },
|
||||||
|
{ tag: "social", type: "filter", applicability: "email", noteEffect: "skip", description: "Social media notifications", example: "John Smith commented on your post." },
|
||||||
|
{ tag: "forums", type: "filter", applicability: "email", noteEffect: "skip", description: "Mailing lists and group discussions", example: "Re: [dev-list] Question about API design" },
|
||||||
|
{ tag: "action-required", type: "action", applicability: "both", noteEffect: "create", description: "Needs a response or action from you", example: "Can you send me the pricing by Friday?" },
|
||||||
|
{ tag: "fyi", type: "action", applicability: "email", noteEffect: "skip", description: "Informational only, no action needed", example: "Just wanted to let you know the deal closed. Thanks for your help!" },
|
||||||
|
{ tag: "urgent", type: "action", applicability: "both", noteEffect: "create", description: "Time-sensitive, needs immediate attention", example: "We need your signature on the contract by EOD today or we lose the deal." },
|
||||||
|
{ tag: "waiting", type: "action", applicability: "both", noteEffect: "create", description: "Waiting on a response from them" },
|
||||||
|
{ tag: "unread", type: "status", applicability: "email", noteEffect: "none", description: "Not yet processed" },
|
||||||
|
{ tag: "to-reply", type: "status", applicability: "email", noteEffect: "none", description: "Need to respond" },
|
||||||
|
{ tag: "done", type: "status", applicability: "email", noteEffect: "none", description: "Handled, can be archived" },
|
||||||
|
{ tag: "active", type: "status", applicability: "notes", noteEffect: "none", description: "Currently relevant, recent activity" },
|
||||||
|
{ tag: "archived", type: "status", applicability: "notes", noteEffect: "none", description: "No longer active, kept for reference" },
|
||||||
|
{ tag: "stale", type: "status", applicability: "notes", noteEffect: "none", description: "No activity in 60+ days, needs attention or archive" },
|
||||||
|
{ tag: "email", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from email" },
|
||||||
|
{ tag: "meeting", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from meeting transcript" },
|
||||||
|
{ tag: "browser", type: "source", applicability: "notes", noteEffect: "none", description: "Content captured from web browsing" },
|
||||||
|
{ tag: "web-search", type: "source", applicability: "notes", noteEffect: "none", description: "Information from web search" },
|
||||||
|
{ tag: "manual", type: "source", applicability: "notes", noteEffect: "none", description: "Manually entered by user" },
|
||||||
|
{ tag: "import", type: "source", applicability: "notes", noteEffect: "none", description: "Imported from another system" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function TagGroupTable({
|
||||||
|
group,
|
||||||
|
tags,
|
||||||
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
onAdd,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
getGlobalIndex,
|
||||||
|
isEmail,
|
||||||
|
}: {
|
||||||
|
group: { type: string; label: string; tags: TagDef[] }
|
||||||
|
tags: TagDef[]
|
||||||
|
collapsed: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
onAdd: () => void
|
||||||
|
onUpdate: (index: number, field: keyof TagDef, value: string | boolean) => void
|
||||||
|
onRemove: (index: number) => void
|
||||||
|
getGlobalIndex: (type: string, localIndex: number) => number
|
||||||
|
isEmail: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className={cn("size-3.5 transition-transform", !collapsed && "rotate-90")} />
|
||||||
|
{group.label}
|
||||||
|
<span className="text-[10px] ml-0.5">({group.tags.length})</span>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={onAdd}
|
||||||
|
>
|
||||||
|
<Plus className="size-3 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!collapsed && group.tags.length > 0 && (
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<div className={cn(
|
||||||
|
"gap-1 bg-muted/50 px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider grid",
|
||||||
|
isEmail ? "grid-cols-[100px_1fr_1fr_60px_24px]" : "grid-cols-[100px_1fr_1fr_24px]"
|
||||||
|
)}>
|
||||||
|
<div>Label</div>
|
||||||
|
<div>Description</div>
|
||||||
|
<div>Example</div>
|
||||||
|
{isEmail && <div className="text-center" title="Emails with this label will be excluded from creating notes">Skip notes</div>}
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
{group.tags.map((tag, localIdx) => {
|
||||||
|
const globalIdx = getGlobalIndex(group.type, localIdx)
|
||||||
|
return (
|
||||||
|
<div key={globalIdx} className={cn(
|
||||||
|
"gap-1 border-t px-2 py-0.5 items-center grid",
|
||||||
|
isEmail ? "grid-cols-[100px_1fr_1fr_60px_24px]" : "grid-cols-[100px_1fr_1fr_24px]"
|
||||||
|
)}>
|
||||||
|
<Input
|
||||||
|
value={tag.tag}
|
||||||
|
onChange={e => onUpdate(globalIdx, "tag", e.target.value)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
placeholder="tag-name"
|
||||||
|
title={tag.tag}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={tag.description}
|
||||||
|
onChange={e => onUpdate(globalIdx, "description", e.target.value)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
placeholder="Description"
|
||||||
|
title={tag.description}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={tag.example || ""}
|
||||||
|
onChange={e => onUpdate(globalIdx, "example", e.target.value)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
placeholder="Example"
|
||||||
|
title={tag.example || ""}
|
||||||
|
/>
|
||||||
|
{isEmail && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Switch
|
||||||
|
checked={tag.noteEffect === "skip"}
|
||||||
|
onCheckedChange={checked => onUpdate(globalIdx, "noteEffect", checked ? "skip" : "create")}
|
||||||
|
className="scale-75"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(globalIdx)}
|
||||||
|
className="flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!collapsed && group.tags.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground italic px-2">No tags in this group</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
|
const [tags, setTags] = useState<TagDef[]>([])
|
||||||
|
const [originalTags, setOriginalTags] = useState<TagDef[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||||
|
const [activeSection, setActiveSection] = useState<"notes" | "email">("notes")
|
||||||
|
|
||||||
|
const hasChanges = JSON.stringify(tags) !== JSON.stringify(originalTags)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dialogOpen) return
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke("workspace:readFile", { path: "config/tags.json" })
|
||||||
|
const parsed = JSON.parse(result.data)
|
||||||
|
setTags(parsed)
|
||||||
|
setOriginalTags(parsed)
|
||||||
|
} catch {
|
||||||
|
setTags([...DEFAULT_TAGS])
|
||||||
|
setOriginalTags([...DEFAULT_TAGS])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [dialogOpen])
|
||||||
|
|
||||||
|
const noteGroups = useMemo(() => {
|
||||||
|
const map = new Map<string, TagDef[]>()
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag.applicability === "email") continue
|
||||||
|
const list = map.get(tag.type) ?? []
|
||||||
|
list.push(tag)
|
||||||
|
map.set(tag.type, list)
|
||||||
|
}
|
||||||
|
return NOTE_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
|
||||||
|
type,
|
||||||
|
label: TAG_TYPE_LABELS[type],
|
||||||
|
tags: map.get(type) ?? [],
|
||||||
|
}))
|
||||||
|
}, [tags])
|
||||||
|
|
||||||
|
const emailGroups = useMemo(() => {
|
||||||
|
const map = new Map<string, TagDef[]>()
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag.applicability === "notes") continue
|
||||||
|
const list = map.get(tag.type) ?? []
|
||||||
|
list.push(tag)
|
||||||
|
map.set(tag.type, list)
|
||||||
|
}
|
||||||
|
return EMAIL_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
|
||||||
|
type,
|
||||||
|
label: TAG_TYPE_LABELS[type],
|
||||||
|
tags: map.get(type) ?? [],
|
||||||
|
}))
|
||||||
|
}, [tags])
|
||||||
|
|
||||||
|
const getGlobalIndex = useCallback((type: string, localIndex: number) => {
|
||||||
|
let count = 0
|
||||||
|
for (let i = 0; i < tags.length; i++) {
|
||||||
|
if (tags[i].type === type) {
|
||||||
|
if (count === localIndex) return i
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, [tags])
|
||||||
|
|
||||||
|
const updateTag = useCallback((index: number, field: keyof TagDef, value: string | boolean) => {
|
||||||
|
setTags(prev => prev.map((t, i) => i === index ? { ...t, [field]: value } : t))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeTag = useCallback((index: number) => {
|
||||||
|
setTags(prev => prev.filter((_, i) => i !== index))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addTag = useCallback((type: string) => {
|
||||||
|
const isEmailSection = activeSection === "email"
|
||||||
|
const applicability = isEmailSection ? "email" as const : "notes" as const
|
||||||
|
// For email-only types, always use "email"; for notes-only types, always use "notes"; otherwise use "both"
|
||||||
|
const emailOnlyTypes = ["email-type", "filter"]
|
||||||
|
const notesOnlyTypes = ["relationship-sub", "source"]
|
||||||
|
let finalApplicability: "email" | "notes" | "both" = "both"
|
||||||
|
if (emailOnlyTypes.includes(type)) finalApplicability = "email"
|
||||||
|
else if (notesOnlyTypes.includes(type)) finalApplicability = "notes"
|
||||||
|
else finalApplicability = isEmailSection ? "email" : applicability
|
||||||
|
|
||||||
|
const newTag: TagDef = {
|
||||||
|
tag: "",
|
||||||
|
type,
|
||||||
|
applicability: finalApplicability === "email" && !isEmailSection ? "both" : finalApplicability === "notes" && isEmailSection ? "both" : finalApplicability,
|
||||||
|
description: "",
|
||||||
|
noteEffect: isEmailSection ? "create" : "none",
|
||||||
|
}
|
||||||
|
const lastIndex = tags.reduce((acc, t, i) => t.type === type ? i : acc, -1)
|
||||||
|
if (lastIndex === -1) {
|
||||||
|
setTags(prev => [...prev, newTag])
|
||||||
|
} else {
|
||||||
|
setTags(prev => [...prev.slice(0, lastIndex + 1), newTag, ...prev.slice(lastIndex + 1)])
|
||||||
|
}
|
||||||
|
}, [tags, activeSection])
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await window.ipc.invoke("workspace:writeFile", {
|
||||||
|
path: "config/tags.json",
|
||||||
|
data: JSON.stringify(tags, null, 2),
|
||||||
|
})
|
||||||
|
setOriginalTags([...tags])
|
||||||
|
toast.success("Tag configuration saved")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save tag configuration")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [tags])
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
if (!confirm("Reset all tags to defaults? This will discard your changes.")) return
|
||||||
|
setTags([...DEFAULT_TAGS])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleGroup = useCallback((type: string) => {
|
||||||
|
setCollapsedGroups(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(type)) next.delete(type)
|
||||||
|
else next.add(type)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="size-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentGroups = activeSection === "notes" ? noteGroups : emailGroups
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex items-center gap-1 mb-3 border-b">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSection("notes")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors",
|
||||||
|
activeSection === "notes"
|
||||||
|
? "border-foreground text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BookOpen className="size-3.5" />
|
||||||
|
Note Tags
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSection("email")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors",
|
||||||
|
activeSection === "email"
|
||||||
|
? "border-foreground text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Mail className="size-3.5" />
|
||||||
|
Email Labels
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 min-h-0">
|
||||||
|
{currentGroups.map(group => (
|
||||||
|
<TagGroupTable
|
||||||
|
key={group.type}
|
||||||
|
group={group}
|
||||||
|
tags={tags}
|
||||||
|
collapsed={collapsedGroups.has(group.type)}
|
||||||
|
onToggle={() => toggleGroup(group.type)}
|
||||||
|
onAdd={() => addTag(group.type)}
|
||||||
|
onUpdate={updateTag}
|
||||||
|
onRemove={removeTag}
|
||||||
|
getGlobalIndex={getGlobalIndex}
|
||||||
|
isEmail={activeSection === "email"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="pt-3 border-t mt-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
{hasChanges && (
|
||||||
|
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||||
|
Reset to defaults
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges}>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Main Settings Dialog ---
|
// --- Main Settings Dialog ---
|
||||||
|
|
||||||
export function SettingsDialog({ children }: SettingsDialogProps) {
|
export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
|
|
@ -483,7 +1125,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||||
if (tab === "appearance" || tab === "models") return
|
if (tab === "appearance" || tab === "models" || tab === "note-tagging") return
|
||||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||||
if (!tabConfig.path) return
|
if (!tabConfig.path) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -589,9 +1231,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : "overflow-hidden")}>
|
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||||
{activeTab === "models" ? (
|
{activeTab === "models" ? (
|
||||||
<ModelSettings dialogOpen={open} />
|
<ModelSettings dialogOpen={open} />
|
||||||
|
) : activeTab === "note-tagging" ? (
|
||||||
|
<NoteTaggingSettings dialogOpen={open} />
|
||||||
) : activeTab === "appearance" ? (
|
) : activeTab === "appearance" ? (
|
||||||
<AppearanceSettings />
|
<AppearanceSettings />
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
Mic,
|
Mic,
|
||||||
Network,
|
Network,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Table2,
|
||||||
Plug,
|
Plug,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
Settings,
|
Settings,
|
||||||
|
|
@ -101,6 +102,7 @@ type KnowledgeActions = {
|
||||||
createNote: (parentPath?: string) => void
|
createNote: (parentPath?: string) => void
|
||||||
createFolder: (parentPath?: string) => void
|
createFolder: (parentPath?: string) => void
|
||||||
openGraph: () => void
|
openGraph: () => void
|
||||||
|
openBases: () => void
|
||||||
expandAll: () => void
|
expandAll: () => void
|
||||||
collapseAll: () => void
|
collapseAll: () => void
|
||||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||||
|
|
@ -855,6 +857,7 @@ function KnowledgeSection({
|
||||||
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
|
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
|
||||||
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
|
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
|
||||||
{ icon: Network, label: "Graph View", action: () => actions.openGraph() },
|
{ icon: Network, label: "Graph View", action: () => actions.openGraph() },
|
||||||
|
{ icon: Table2, label: "Bases", action: () => actions.openBases() },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
177
apps/x/apps/renderer/src/components/version-history-panel.tsx
Normal file
177
apps/x/apps/renderer/src/components/version-history-panel.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { X, Clock } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface CommitInfo {
|
||||||
|
oid: string
|
||||||
|
message: string
|
||||||
|
timestamp: number
|
||||||
|
author: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionHistoryPanelProps {
|
||||||
|
path: string // knowledge-relative file path (e.g. "knowledge/People/John.md")
|
||||||
|
onClose: () => void
|
||||||
|
onSelectVersion: (oid: string | null, content: string) => void // null = current
|
||||||
|
onRestore: (oid: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(unixSeconds: number): { date: string; time: string } {
|
||||||
|
const d = new Date(unixSeconds * 1000)
|
||||||
|
const date = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
|
||||||
|
const time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
|
||||||
|
return { date, time }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionHistoryPanel({
|
||||||
|
path,
|
||||||
|
onClose,
|
||||||
|
onSelectVersion,
|
||||||
|
onRestore,
|
||||||
|
}: VersionHistoryPanelProps) {
|
||||||
|
const [commits, setCommits] = useState<CommitInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedOid, setSelectedOid] = useState<string | null>(null) // null = current/latest
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Strip "knowledge/" prefix for IPC calls
|
||||||
|
const relPath = path.startsWith('knowledge/') ? path.slice('knowledge/'.length) : path
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('knowledge:history', { path: relPath })
|
||||||
|
setCommits(result.commits)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load version history:', err)
|
||||||
|
setError('Failed to load history')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [relPath])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHistory()
|
||||||
|
}, [loadHistory])
|
||||||
|
|
||||||
|
// Refresh when new commits land
|
||||||
|
useEffect(() => {
|
||||||
|
return window.ipc.on('knowledge:didCommit', () => {
|
||||||
|
loadHistory()
|
||||||
|
})
|
||||||
|
}, [loadHistory])
|
||||||
|
|
||||||
|
const handleSelectCommit = useCallback(async (oid: string, isLatest: boolean) => {
|
||||||
|
if (isLatest) {
|
||||||
|
setSelectedOid(null)
|
||||||
|
// Read current file content
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('workspace:readFile', { path })
|
||||||
|
onSelectVersion(null, result.data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to read current file:', err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedOid(oid)
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('knowledge:fileAtCommit', { path: relPath, oid })
|
||||||
|
onSelectVersion(oid, result.content)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load file at commit:', err)
|
||||||
|
}
|
||||||
|
}, [path, relPath, onSelectVersion])
|
||||||
|
|
||||||
|
const handleRestore = useCallback(() => {
|
||||||
|
if (selectedOid) {
|
||||||
|
onRestore(selectedOid)
|
||||||
|
}
|
||||||
|
}, [selectedOid, onRestore])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-[280px] shrink-0 border-l border-border bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
|
||||||
|
<span className="text-sm font-medium text-foreground">Version history</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
aria-label="Close version history"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Commit list */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : commits.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
No history available
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-1">
|
||||||
|
{commits.map((commit, index) => {
|
||||||
|
const isLatest = index === 0
|
||||||
|
const isSelected = isLatest ? selectedOid === null : selectedOid === commit.oid
|
||||||
|
const { date, time } = formatTimestamp(commit.timestamp)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={commit.oid}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectCommit(commit.oid, isLatest)}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'bg-accent'
|
||||||
|
: 'hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{!isLatest && (
|
||||||
|
<Clock className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{date} · {time}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isLatest && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Current version
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{selectedOid && (
|
||||||
|
<div className="shrink-0 border-t border-border p-3">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleRestore}
|
||||||
|
>
|
||||||
|
Restore this version
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
apps/x/apps/renderer/src/extensions/task-block.tsx
Normal file
98
apps/x/apps/renderer/src/extensions/task-block.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { mergeAttributes, Node } from '@tiptap/react'
|
||||||
|
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||||
|
import { CalendarClock, X } from 'lucide-react'
|
||||||
|
import { inlineTask } from '@x/shared'
|
||||||
|
|
||||||
|
function TaskBlockView({ node, deleteNode }: { node: { attrs: { data: string } }; deleteNode: () => void }) {
|
||||||
|
const raw = node.attrs.data
|
||||||
|
let instruction = ''
|
||||||
|
let scheduleLabel = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = inlineTask.InlineTaskBlockSchema.parse(JSON.parse(raw))
|
||||||
|
instruction = parsed.instruction
|
||||||
|
scheduleLabel = parsed['schedule-label'] ?? ''
|
||||||
|
} catch {
|
||||||
|
// Fallback: show raw data
|
||||||
|
instruction = raw
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className="task-block-wrapper" data-type="task-block">
|
||||||
|
<div className="task-block-card">
|
||||||
|
<button
|
||||||
|
className="task-block-delete"
|
||||||
|
onClick={deleteNode}
|
||||||
|
aria-label="Delete task block"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
<div className="task-block-content">
|
||||||
|
<span className="task-block-instruction"><span className="task-block-prefix">@rowboat</span> {instruction}</span>
|
||||||
|
{scheduleLabel && (
|
||||||
|
<span className="task-block-schedule">
|
||||||
|
<CalendarClock size={12} />
|
||||||
|
{scheduleLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskBlockExtension = Node.create({
|
||||||
|
name: 'taskBlock',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: false,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
default: '{}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'pre',
|
||||||
|
priority: 60,
|
||||||
|
getAttrs(element) {
|
||||||
|
const code = element.querySelector('code')
|
||||||
|
if (!code) return false
|
||||||
|
const cls = code.className || ''
|
||||||
|
if (cls.includes('language-task') || cls.includes('language-tell-rowboat')) {
|
||||||
|
return { data: code.textContent || '{}' }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||||
|
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'task-block' })]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(TaskBlockView)
|
||||||
|
},
|
||||||
|
|
||||||
|
addStorage() {
|
||||||
|
return {
|
||||||
|
markdown: {
|
||||||
|
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||||
|
state.write('```task\n' + node.attrs.data + '\n```')
|
||||||
|
state.closeBlock(node)
|
||||||
|
},
|
||||||
|
parse: {
|
||||||
|
// handled by parseHTML
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
107
apps/x/apps/renderer/src/lib/attachment-presentation.ts
Normal file
107
apps/x/apps/renderer/src/lib/attachment-presentation.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { getExtension } from '@/lib/file-utils'
|
||||||
|
|
||||||
|
export type AttachmentLike = {
|
||||||
|
filename?: string
|
||||||
|
path: string
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttachmentIconKind =
|
||||||
|
| 'audio'
|
||||||
|
| 'video'
|
||||||
|
| 'spreadsheet'
|
||||||
|
| 'archive'
|
||||||
|
| 'code'
|
||||||
|
| 'text'
|
||||||
|
| 'file'
|
||||||
|
|
||||||
|
const ARCHIVE_EXTENSIONS = new Set([
|
||||||
|
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
|
||||||
|
])
|
||||||
|
|
||||||
|
const SPREADSHEET_EXTENSIONS = new Set([
|
||||||
|
'csv', 'tsv', 'xls', 'xlsx',
|
||||||
|
])
|
||||||
|
|
||||||
|
const CODE_EXTENSIONS = new Set([
|
||||||
|
'js', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'toml', 'xml',
|
||||||
|
'py', 'rb', 'go', 'rs', 'java', 'kt', 'c', 'cpp', 'h', 'hpp',
|
||||||
|
'cs', 'php', 'swift', 'sh', 'sql', 'html', 'css', 'scss', 'md',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function getAttachmentDisplayName(attachment: AttachmentLike): string {
|
||||||
|
if (attachment.filename) return attachment.filename
|
||||||
|
const fromPath = attachment.path.split(/[\\/]/).pop()
|
||||||
|
return fromPath || attachment.path
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentTypeLabel(attachment: AttachmentLike): string {
|
||||||
|
const ext = getExtension(getAttachmentDisplayName(attachment))
|
||||||
|
if (ext) return ext.toUpperCase()
|
||||||
|
|
||||||
|
const mediaType = attachment.mimeType.toLowerCase()
|
||||||
|
if (mediaType.startsWith('audio/')) return 'AUDIO'
|
||||||
|
if (mediaType.startsWith('video/')) return 'VIDEO'
|
||||||
|
if (mediaType.startsWith('text/')) return 'TEXT'
|
||||||
|
if (mediaType.startsWith('image/')) return 'IMAGE'
|
||||||
|
|
||||||
|
const [, subtypeRaw = 'file'] = mediaType.split('/')
|
||||||
|
const subtype = subtypeRaw.split(';')[0].split('+').pop() || 'file'
|
||||||
|
const cleaned = subtype.replace(/[^a-z0-9]/gi, '')
|
||||||
|
return cleaned ? cleaned.toUpperCase() : 'FILE'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentIconKind(attachment: AttachmentLike): AttachmentIconKind {
|
||||||
|
const mediaType = attachment.mimeType.toLowerCase()
|
||||||
|
const ext = getExtension(attachment.filename || attachment.path)
|
||||||
|
|
||||||
|
if (mediaType.startsWith('audio/')) return 'audio'
|
||||||
|
if (mediaType.startsWith('video/')) return 'video'
|
||||||
|
if (mediaType.includes('spreadsheet') || SPREADSHEET_EXTENSIONS.has(ext)) return 'spreadsheet'
|
||||||
|
if (mediaType.includes('zip') || mediaType.includes('compressed') || ARCHIVE_EXTENSIONS.has(ext)) return 'archive'
|
||||||
|
if (
|
||||||
|
mediaType.includes('json')
|
||||||
|
|| mediaType.includes('javascript')
|
||||||
|
|| mediaType.includes('typescript')
|
||||||
|
|| mediaType.includes('xml')
|
||||||
|
|| CODE_EXTENSIONS.has(ext)
|
||||||
|
) {
|
||||||
|
return 'code'
|
||||||
|
}
|
||||||
|
if (mediaType.startsWith('text/') || mediaType.includes('pdf') || mediaType.includes('document')) {
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'file'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentToneClass(typeLabel: string): string {
|
||||||
|
switch (typeLabel) {
|
||||||
|
case 'PDF':
|
||||||
|
return 'bg-red-500 text-white'
|
||||||
|
case 'CSV':
|
||||||
|
case 'XLS':
|
||||||
|
case 'XLSX':
|
||||||
|
case 'TSV':
|
||||||
|
return 'bg-emerald-500 text-white'
|
||||||
|
case 'ZIP':
|
||||||
|
case 'RAR':
|
||||||
|
case '7Z':
|
||||||
|
case 'TAR':
|
||||||
|
case 'GZ':
|
||||||
|
return 'bg-amber-500 text-white'
|
||||||
|
case 'MP3':
|
||||||
|
case 'WAV':
|
||||||
|
case 'M4A':
|
||||||
|
case 'FLAC':
|
||||||
|
case 'AAC':
|
||||||
|
return 'bg-fuchsia-500 text-white'
|
||||||
|
case 'MP4':
|
||||||
|
case 'MOV':
|
||||||
|
case 'AVI':
|
||||||
|
case 'WEBM':
|
||||||
|
return 'bg-violet-500 text-white'
|
||||||
|
default:
|
||||||
|
return 'bg-primary/85 text-primary-foreground'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,19 @@ import type { ToolUIPart } from 'ai'
|
||||||
import z from 'zod'
|
import z from 'zod'
|
||||||
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
||||||
|
|
||||||
|
export interface MessageAttachment {
|
||||||
|
path: string
|
||||||
|
filename: string
|
||||||
|
mimeType: string
|
||||||
|
size?: number
|
||||||
|
thumbnailUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
|
attachments?: MessageAttachment[]
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
61
apps/x/apps/renderer/src/lib/file-utils.ts
Normal file
61
apps/x/apps/renderer/src/lib/file-utils.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
const IMAGE_MIMES = new Set([
|
||||||
|
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
|
||||||
|
'image/svg+xml', 'image/bmp', 'image/tiff', 'image/ico', 'image/avif',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||||
|
// Images
|
||||||
|
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
||||||
|
webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/ico',
|
||||||
|
avif: 'image/avif', tiff: 'image/tiff',
|
||||||
|
// Text / code
|
||||||
|
txt: 'text/plain', md: 'text/markdown', html: 'text/html', css: 'text/css',
|
||||||
|
csv: 'text/csv', xml: 'text/xml',
|
||||||
|
js: 'text/javascript', ts: 'text/typescript', jsx: 'text/javascript',
|
||||||
|
tsx: 'text/typescript', json: 'application/json', yaml: 'text/yaml',
|
||||||
|
yml: 'text/yaml', toml: 'text/toml',
|
||||||
|
py: 'text/x-python', rb: 'text/x-ruby', rs: 'text/x-rust',
|
||||||
|
go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++',
|
||||||
|
h: 'text/x-c', hpp: 'text/x-c++', sh: 'text/x-shellscript',
|
||||||
|
// Documents
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
// Archives
|
||||||
|
zip: 'application/zip',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isImageMime(mimeType: string): boolean {
|
||||||
|
return IMAGE_MIMES.has(mimeType) || mimeType.startsWith('image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMimeFromExtension(ext: string): string {
|
||||||
|
const normalized = ext.toLowerCase().replace(/^\./, '');
|
||||||
|
return EXTENSION_TO_MIME[normalized] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileDisplayName(filePath: string): string {
|
||||||
|
return filePath.split('/').pop() || filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExtension(filePath: string): string {
|
||||||
|
const name = filePath.split('/').pop() || '';
|
||||||
|
const dotIndex = name.lastIndexOf('.');
|
||||||
|
return dotIndex > 0 ? name.slice(dotIndex + 1).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toFileUrl(filePath: string): string {
|
||||||
|
if (!filePath) return filePath;
|
||||||
|
if (
|
||||||
|
filePath.startsWith('data:') ||
|
||||||
|
filePath.startsWith('file://') ||
|
||||||
|
filePath.startsWith('http://') ||
|
||||||
|
filePath.startsWith('https://')
|
||||||
|
) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
const normalized = filePath.replace(/\\/g, '/');
|
||||||
|
const encoded = encodeURI(normalized);
|
||||||
|
if (/^[A-Za-z]:\//.test(normalized)) {
|
||||||
|
return `file:///${encoded}`;
|
||||||
|
}
|
||||||
|
return `file://${encoded}`;
|
||||||
|
}
|
||||||
367
apps/x/apps/renderer/src/lib/frontmatter.ts
Normal file
367
apps/x/apps/renderer/src/lib/frontmatter.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
/**
|
||||||
|
* Utilities for splitting, joining, and extracting tags from YAML frontmatter
|
||||||
|
* in knowledge notes and email files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Split content into raw frontmatter block and body text. */
|
||||||
|
export function splitFrontmatter(content: string): { raw: string | null; body: string } {
|
||||||
|
if (!content.startsWith('---')) {
|
||||||
|
return { raw: null, body: content }
|
||||||
|
}
|
||||||
|
const endIndex = content.indexOf('\n---', 3)
|
||||||
|
if (endIndex === -1) {
|
||||||
|
return { raw: null, body: content }
|
||||||
|
}
|
||||||
|
// raw includes both delimiters and the trailing newline after closing ---
|
||||||
|
const closingEnd = endIndex + 4 // '\n---' is 4 chars
|
||||||
|
const raw = content.slice(0, closingEnd)
|
||||||
|
// body starts after the closing --- and its trailing newline
|
||||||
|
let body = content.slice(closingEnd)
|
||||||
|
if (body.startsWith('\n')) {
|
||||||
|
body = body.slice(1)
|
||||||
|
}
|
||||||
|
return { raw, body }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-prepend raw frontmatter before body when saving. */
|
||||||
|
export function joinFrontmatter(raw: string | null, body: string): string {
|
||||||
|
if (!raw) return body
|
||||||
|
return raw + '\n' + body
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structured frontmatter fields extracted from categorized YAML. */
|
||||||
|
export type FrontmatterFields = {
|
||||||
|
relationship: string | null
|
||||||
|
relationship_sub: string[]
|
||||||
|
topic: string[]
|
||||||
|
email_type: string[]
|
||||||
|
action: string[]
|
||||||
|
status: string | null
|
||||||
|
source: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract structured tag categories from raw frontmatter YAML.
|
||||||
|
*
|
||||||
|
* Handles both the new categorized format (top-level keys) and the legacy
|
||||||
|
* flat `tags:` list. For legacy notes the flat tags are mapped into
|
||||||
|
* categories using known tag values.
|
||||||
|
*/
|
||||||
|
export function extractFrontmatterFields(raw: string | null): FrontmatterFields {
|
||||||
|
const fields: FrontmatterFields = {
|
||||||
|
relationship: null,
|
||||||
|
relationship_sub: [],
|
||||||
|
topic: [],
|
||||||
|
email_type: [],
|
||||||
|
action: [],
|
||||||
|
status: null,
|
||||||
|
source: [],
|
||||||
|
}
|
||||||
|
if (!raw) return fields
|
||||||
|
|
||||||
|
const lines = raw.split('\n')
|
||||||
|
let currentKey: string | null = null
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Top-level key detection
|
||||||
|
const topMatch = line.match(/^(\w+):\s*(.*)$/)
|
||||||
|
if (topMatch || line === '---') {
|
||||||
|
currentKey = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topMatch) {
|
||||||
|
const key = topMatch[1]
|
||||||
|
const value = topMatch[2].trim()
|
||||||
|
|
||||||
|
if (key in fields) {
|
||||||
|
currentKey = key
|
||||||
|
if (value) {
|
||||||
|
const field = fields[key as keyof FrontmatterFields]
|
||||||
|
if (Array.isArray(field)) {
|
||||||
|
(field as string[]).push(value)
|
||||||
|
} else {
|
||||||
|
// single-value field
|
||||||
|
;(fields as Record<string, unknown>)[key] = value
|
||||||
|
}
|
||||||
|
currentKey = null // inline value, no list follows
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy flat tags: — parse and distribute into categories
|
||||||
|
if (key === 'tags') {
|
||||||
|
currentKey = '__legacy_tags'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List items under a categorized key
|
||||||
|
if (currentKey && currentKey !== '__legacy_tags') {
|
||||||
|
const itemMatch = line.match(/^\s+-\s+(.+)$/)
|
||||||
|
if (itemMatch) {
|
||||||
|
const value = itemMatch[1].trim()
|
||||||
|
const field = fields[currentKey as keyof FrontmatterFields]
|
||||||
|
if (Array.isArray(field)) {
|
||||||
|
(field as string[]).push(value)
|
||||||
|
} else {
|
||||||
|
;(fields as Record<string, unknown>)[currentKey] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy flat tag items → map into categories
|
||||||
|
if (currentKey === '__legacy_tags') {
|
||||||
|
const itemMatch = line.match(/^\s+-\s+(.+)$/)
|
||||||
|
if (itemMatch) {
|
||||||
|
const tag = itemMatch[1].trim()
|
||||||
|
const cat = LEGACY_TAG_TO_CATEGORY[tag]
|
||||||
|
if (cat) {
|
||||||
|
const field = fields[cat as keyof FrontmatterFields]
|
||||||
|
if (Array.isArray(field)) {
|
||||||
|
(field as string[]).push(tag)
|
||||||
|
} else if (!(fields as Record<string, unknown>)[cat]) {
|
||||||
|
;(fields as Record<string, unknown>)[cat] = tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract ALL top-level YAML key/value pairs from raw frontmatter.
|
||||||
|
* Returns a flat record where scalar values are strings and list values are string[].
|
||||||
|
* Skips `---` delimiters and blank lines.
|
||||||
|
*/
|
||||||
|
export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
|
||||||
|
const result: Record<string, string | string[]> = {}
|
||||||
|
if (!raw) return result
|
||||||
|
|
||||||
|
const lines = raw.split('\n')
|
||||||
|
let currentKey: string | null = null
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line === '---' || line.trim() === '') {
|
||||||
|
currentKey = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level key: value
|
||||||
|
const topMatch = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/)
|
||||||
|
if (topMatch) {
|
||||||
|
const key = topMatch[1]
|
||||||
|
const value = topMatch[2].trim()
|
||||||
|
if (value) {
|
||||||
|
result[key] = value
|
||||||
|
currentKey = null
|
||||||
|
} else {
|
||||||
|
// List will follow
|
||||||
|
currentKey = key
|
||||||
|
result[key] = []
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// List item under current key
|
||||||
|
if (currentKey) {
|
||||||
|
const itemMatch = line.match(/^\s+-\s+(.+)$/)
|
||||||
|
if (itemMatch) {
|
||||||
|
const arr = result[currentKey]
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
arr.push(itemMatch[1].trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Record of frontmatter fields back to a raw YAML frontmatter string.
|
||||||
|
* Returns null if no non-empty fields remain.
|
||||||
|
*/
|
||||||
|
export function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
|
||||||
|
const lines: string[] = []
|
||||||
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) continue
|
||||||
|
lines.push(`${key}:`)
|
||||||
|
for (const item of value) {
|
||||||
|
if (item.trim()) lines.push(` - ${item.trim()}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const trimmed = (value ?? '').trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
lines.push(`${key}: ${trimmed}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lines.length === 0) return null
|
||||||
|
return `---\n${lines.join('\n')}\n---`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map known tag values → category for legacy flat-list frontmatter. */
|
||||||
|
const LEGACY_TAG_TO_CATEGORY: Record<string, string> = {
|
||||||
|
// relationship
|
||||||
|
investor: 'relationship', customer: 'relationship', prospect: 'relationship',
|
||||||
|
partner: 'relationship', vendor: 'relationship', product: 'relationship',
|
||||||
|
candidate: 'relationship', team: 'relationship', advisor: 'relationship',
|
||||||
|
personal: 'relationship', press: 'relationship', community: 'relationship',
|
||||||
|
government: 'relationship',
|
||||||
|
// relationship_sub
|
||||||
|
primary: 'relationship_sub', secondary: 'relationship_sub',
|
||||||
|
'executive-assistant': 'relationship_sub', cc: 'relationship_sub',
|
||||||
|
'referred-by': 'relationship_sub', former: 'relationship_sub',
|
||||||
|
champion: 'relationship_sub', blocker: 'relationship_sub',
|
||||||
|
// topic
|
||||||
|
sales: 'topic', support: 'topic', legal: 'topic', finance: 'topic',
|
||||||
|
hiring: 'topic', fundraising: 'topic', travel: 'topic', event: 'topic',
|
||||||
|
shopping: 'topic', health: 'topic', learning: 'topic', research: 'topic',
|
||||||
|
// email_type
|
||||||
|
intro: 'email_type', followup: 'email_type',
|
||||||
|
// action
|
||||||
|
'action-required': 'action', urgent: 'action', waiting: 'action',
|
||||||
|
// status
|
||||||
|
active: 'status', archived: 'status', stale: 'status',
|
||||||
|
// source
|
||||||
|
email: 'source', meeting: 'source', browser: 'source',
|
||||||
|
'web-search': 'source', manual: 'source', import: 'source',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tag category keys used in the categorized frontmatter format. */
|
||||||
|
const TAG_CATEGORY_KEYS = new Set([
|
||||||
|
'relationship',
|
||||||
|
'relationship_sub',
|
||||||
|
'topic',
|
||||||
|
'email_type',
|
||||||
|
'action',
|
||||||
|
'status',
|
||||||
|
'source',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** Keys that are metadata, not tags — skip when collecting tags. */
|
||||||
|
const METADATA_KEYS = new Set(['processed', 'labeled_at', 'tagged_at'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract tags from raw frontmatter YAML.
|
||||||
|
*
|
||||||
|
* Handles three formats:
|
||||||
|
* - Legacy flat list: `tags:` followed by ` - value` items
|
||||||
|
* - Categorized format: top-level keys like `relationship: customer` or
|
||||||
|
* `topic:` followed by ` - value` list items
|
||||||
|
* - Email format: `labels:` with nested keys (relationship, topics, type, filter, action)
|
||||||
|
* where values can be single strings or ` - value` arrays
|
||||||
|
*
|
||||||
|
* Skips metadata keys like `processed`, `labeled_at`, `tagged_at`.
|
||||||
|
*/
|
||||||
|
export function extractTags(raw: string | null): string[] {
|
||||||
|
if (!raw) return []
|
||||||
|
|
||||||
|
const lines = raw.split('\n')
|
||||||
|
const tags: string[] = []
|
||||||
|
|
||||||
|
let inTags = false
|
||||||
|
let inLabels = false
|
||||||
|
let inLabelSubKey = false
|
||||||
|
let inCategoryList = false
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Top-level key detection — resets all nested state
|
||||||
|
if (/^\w/.test(line) || line === '---') {
|
||||||
|
inTags = false
|
||||||
|
inLabels = false
|
||||||
|
inLabelSubKey = false
|
||||||
|
inCategoryList = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy note format: tags:
|
||||||
|
if (/^tags:\s*$/.test(line)) {
|
||||||
|
inTags = true
|
||||||
|
inLabels = false
|
||||||
|
inCategoryList = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email format: labels:
|
||||||
|
if (/^labels:\s*$/.test(line)) {
|
||||||
|
inLabels = true
|
||||||
|
inTags = false
|
||||||
|
inCategoryList = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categorized format: top-level tag category key
|
||||||
|
const topKeyMatch = line.match(/^(\w+):\s*(.*)$/)
|
||||||
|
if (topKeyMatch) {
|
||||||
|
const key = topKeyMatch[1]
|
||||||
|
const inlineValue = topKeyMatch[2].trim()
|
||||||
|
|
||||||
|
if (TAG_CATEGORY_KEYS.has(key)) {
|
||||||
|
if (inlineValue) {
|
||||||
|
// Single value: `relationship: customer`
|
||||||
|
tags.push(inlineValue)
|
||||||
|
inCategoryList = false
|
||||||
|
} else {
|
||||||
|
// List follows: `topic:\n - sales`
|
||||||
|
inCategoryList = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect tag items under `tags:`
|
||||||
|
if (inTags) {
|
||||||
|
const match = line.match(/^\s+-\s+(.+)$/)
|
||||||
|
if (match) {
|
||||||
|
tags.push(match[1].trim())
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect list items under a category key
|
||||||
|
if (inCategoryList) {
|
||||||
|
const match = line.match(/^\s+-\s+(.+)$/)
|
||||||
|
if (match) {
|
||||||
|
tags.push(match[1].trim())
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle labels: nested structure
|
||||||
|
if (inLabels) {
|
||||||
|
// Sub-key like ` relationship:` or ` topics:`
|
||||||
|
const subKeyMatch = line.match(/^\s{2}(\w+):\s*(.*)$/)
|
||||||
|
if (subKeyMatch) {
|
||||||
|
const key = subKeyMatch[1]
|
||||||
|
const inlineValue = subKeyMatch[2].trim()
|
||||||
|
if (METADATA_KEYS.has(key)) {
|
||||||
|
inLabelSubKey = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (inlineValue) {
|
||||||
|
// Inline value like ` type: person`
|
||||||
|
tags.push(inlineValue)
|
||||||
|
inLabelSubKey = false
|
||||||
|
} else {
|
||||||
|
// Array follows
|
||||||
|
inLabelSubKey = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array item under a sub-key like ` - value`
|
||||||
|
if (inLabelSubKey) {
|
||||||
|
const itemMatch = line.match(/^\s{4}-\s+(.+)$/)
|
||||||
|
if (itemMatch) {
|
||||||
|
tags.push(itemMatch[1].trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
@ -237,6 +237,200 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Frontmatter properties panel between toolbar and editor content */
|
||||||
|
.frontmatter-properties {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background-color: var(--background);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||||
|
font-size: 12px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-toggle:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
background-color: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-chevron {
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-chevron.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-fields {
|
||||||
|
padding: 2px 12px 6px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-key {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 110px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-value-area {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-input {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--foreground);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-input:focus {
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-input:read-only {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-new-key-input {
|
||||||
|
width: 110px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-row:hover .frontmatter-remove {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-remove:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-add:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Array field chips */
|
||||||
|
.frontmatter-array {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .frontmatter-chip {
|
||||||
|
background-color: color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-chip-text {
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-chip-remove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-chip-remove:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-chip-input {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--foreground);
|
||||||
|
width: 60px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-chip-input::placeholder {
|
||||||
|
color: color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.editor-toolbar .separator {
|
.editor-toolbar .separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
|
|
@ -337,6 +531,83 @@
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Task block */
|
||||||
|
.tiptap-editor .ProseMirror .task-block-wrapper {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .task-block-card {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||||
|
cursor: default;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .task-block-delete {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .task-block-card:hover .task-block-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .task-block-delete:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .task-block-card:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--muted) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .task-block-wrapper.ProseMirror-selectednode .task-block-card {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .task-block-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .task-block-prefix {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .task-block-instruction {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .ProseMirror .task-block-schedule {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode overrides */
|
/* Dark mode overrides */
|
||||||
.dark .tiptap-editor .ProseMirror {
|
.dark .tiptap-editor .ProseMirror {
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
|
@ -358,6 +629,10 @@
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror pre code {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.dark .tiptap-editor .ProseMirror code {
|
.dark .tiptap-editor .ProseMirror code {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #ff7b72;
|
color: #ff7b72;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"glob": "^13.0.0",
|
"glob": "^13.0.0",
|
||||||
"google-auth-library": "^10.5.0",
|
"google-auth-library": "^10.5.0",
|
||||||
|
"isomorphic-git": "^1.29.0",
|
||||||
"googleapis": "^169.0.0",
|
"googleapis": "^169.0.0",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"node-html-markdown": "^2.0.0",
|
"node-html-markdown": "^2.0.0",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { jsonSchema, ModelMessage } from "ai";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { WorkDir } from "../config/config.js";
|
import { WorkDir } from "../config/config.js";
|
||||||
import { getNoteCreationStrictness } from "../config/note_creation_config.js";
|
|
||||||
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
||||||
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js";
|
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js";
|
||||||
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
|
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
|
||||||
|
|
@ -25,9 +24,10 @@ import { IRunsLock } from "../runs/lock.js";
|
||||||
import { IAbortRegistry } from "../runs/abort-registry.js";
|
import { IAbortRegistry } from "../runs/abort-registry.js";
|
||||||
import { PrefixLogger } from "@x/shared";
|
import { PrefixLogger } from "@x/shared";
|
||||||
import { parse } from "yaml";
|
import { parse } from "yaml";
|
||||||
import { raw as noteCreationMediumRaw } from "../knowledge/note_creation_medium.js";
|
import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js";
|
||||||
import { raw as noteCreationLowRaw } from "../knowledge/note_creation_low.js";
|
import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
||||||
import { raw as noteCreationHighRaw } from "../knowledge/note_creation_high.js";
|
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
|
||||||
|
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
|
||||||
|
|
||||||
export interface IAgentRuntime {
|
export interface IAgentRuntime {
|
||||||
trigger(runId: string): Promise<void>;
|
trigger(runId: string): Promise<void>;
|
||||||
|
|
@ -316,19 +316,7 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === 'note_creation') {
|
if (id === 'note_creation') {
|
||||||
const strictness = getNoteCreationStrictness();
|
const raw = getNoteCreationRaw();
|
||||||
let raw = '';
|
|
||||||
switch (strictness) {
|
|
||||||
case 'medium':
|
|
||||||
raw = noteCreationMediumRaw;
|
|
||||||
break;
|
|
||||||
case 'low':
|
|
||||||
raw = noteCreationLowRaw;
|
|
||||||
break;
|
|
||||||
case 'high':
|
|
||||||
raw = noteCreationHighRaw;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let agent: z.infer<typeof Agent> = {
|
let agent: z.infer<typeof Agent> = {
|
||||||
name: id,
|
name: id,
|
||||||
instructions: raw,
|
instructions: raw,
|
||||||
|
|
@ -353,10 +341,91 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (id === 'labeling_agent') {
|
||||||
|
const labelingAgentRaw = getLabelingAgentRaw();
|
||||||
|
let agent: z.infer<typeof Agent> = {
|
||||||
|
name: id,
|
||||||
|
instructions: labelingAgentRaw,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (labelingAgentRaw.startsWith("---")) {
|
||||||
|
const end = labelingAgentRaw.indexOf("\n---", 3);
|
||||||
|
if (end !== -1) {
|
||||||
|
const fm = labelingAgentRaw.slice(3, end).trim();
|
||||||
|
const content = labelingAgentRaw.slice(end + 4).trim();
|
||||||
|
const yaml = parse(fm);
|
||||||
|
const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml);
|
||||||
|
agent = {
|
||||||
|
...agent,
|
||||||
|
...parsed,
|
||||||
|
instructions: content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'note_tagging_agent') {
|
||||||
|
const noteTaggingAgentRaw = getNoteTaggingAgentRaw();
|
||||||
|
let agent: z.infer<typeof Agent> = {
|
||||||
|
name: id,
|
||||||
|
instructions: noteTaggingAgentRaw,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (noteTaggingAgentRaw.startsWith("---")) {
|
||||||
|
const end = noteTaggingAgentRaw.indexOf("\n---", 3);
|
||||||
|
if (end !== -1) {
|
||||||
|
const fm = noteTaggingAgentRaw.slice(3, end).trim();
|
||||||
|
const content = noteTaggingAgentRaw.slice(end + 4).trim();
|
||||||
|
const yaml = parse(fm);
|
||||||
|
const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml);
|
||||||
|
agent = {
|
||||||
|
...agent,
|
||||||
|
...parsed,
|
||||||
|
instructions: content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'inline_task_agent') {
|
||||||
|
const inlineTaskAgentRaw = getInlineTaskAgentRaw();
|
||||||
|
let agent: z.infer<typeof Agent> = {
|
||||||
|
name: id,
|
||||||
|
instructions: inlineTaskAgentRaw,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (inlineTaskAgentRaw.startsWith("---")) {
|
||||||
|
const end = inlineTaskAgentRaw.indexOf("\n---", 3);
|
||||||
|
if (end !== -1) {
|
||||||
|
const fm = inlineTaskAgentRaw.slice(3, end).trim();
|
||||||
|
const content = inlineTaskAgentRaw.slice(end + 4).trim();
|
||||||
|
const yaml = parse(fm);
|
||||||
|
const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml);
|
||||||
|
agent = {
|
||||||
|
...agent,
|
||||||
|
...parsed,
|
||||||
|
instructions: content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||||
return await repo.fetch(id);
|
return await repo.fetch(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
|
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
|
||||||
const result: ModelMessage[] = [];
|
const result: ModelMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
|
|
@ -400,11 +469,37 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "user":
|
case "user":
|
||||||
result.push({
|
if (typeof msg.content === 'string') {
|
||||||
role: "user",
|
// Legacy string — pass through unchanged
|
||||||
content: msg.content,
|
result.push({
|
||||||
providerOptions,
|
role: "user",
|
||||||
});
|
content: msg.content,
|
||||||
|
providerOptions,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// New content parts array — collapse to text for LLM
|
||||||
|
const textSegments: string[] = [];
|
||||||
|
const attachmentLines: string[] = [];
|
||||||
|
|
||||||
|
for (const part of msg.content) {
|
||||||
|
if (part.type === "attachment") {
|
||||||
|
const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
|
||||||
|
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`);
|
||||||
|
} else {
|
||||||
|
textSegments.push(part.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachmentLines.length > 0) {
|
||||||
|
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
role: "user",
|
||||||
|
content: textSegments.join("\n"),
|
||||||
|
providerOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "tool":
|
case "tool":
|
||||||
result.push({
|
result.push({
|
||||||
|
|
@ -674,7 +769,12 @@ export async function* streamAgent({
|
||||||
|
|
||||||
// set up provider + model
|
// set up provider + model
|
||||||
const provider = createProvider(modelConfig.provider);
|
const provider = createProvider(modelConfig.provider);
|
||||||
const model = provider.languageModel(modelConfig.model);
|
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent"];
|
||||||
|
const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel)
|
||||||
|
? modelConfig.knowledgeGraphModel
|
||||||
|
: modelConfig.model;
|
||||||
|
const model = provider.languageModel(modelId);
|
||||||
|
logger.log(`using model: ${modelId}`);
|
||||||
|
|
||||||
let loopCounter = 0;
|
let loopCounter = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { skillCatalog } from "./skills/index.js";
|
import { skillCatalog } from "./skills/index.js";
|
||||||
import { WorkDir as BASE_DIR } from "../../config/config.js";
|
import { WorkDir as BASE_DIR } from "../../config/config.js";
|
||||||
|
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
|
||||||
|
|
||||||
|
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||||
|
|
||||||
export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
|
export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
|
||||||
|
|
||||||
|
|
@ -150,18 +153,22 @@ When a user asks for ANY task that might require external capabilities (web sear
|
||||||
- Use relative paths (no \`\${BASE_DIR}\` prefixes) when running commands or referencing files.
|
- Use relative paths (no \`\${BASE_DIR}\` prefixes) when running commands or referencing files.
|
||||||
- Keep user data safe—double-check before editing or deleting important resources.
|
- Keep user data safe—double-check before editing or deleting important resources.
|
||||||
|
|
||||||
|
${runtimeContextPrompt}
|
||||||
|
|
||||||
## Workspace Access & Scope
|
## Workspace Access & Scope
|
||||||
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
|
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
|
||||||
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
|
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
|
||||||
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
|
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
|
||||||
|
|
||||||
**CRITICAL - When the user asks you to work with files outside ~/.rowboat:**
|
**CRITICAL - When the user asks you to work with files outside ~/.rowboat:**
|
||||||
- The user is on **macOS**. Use macOS paths and commands (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` command).
|
- Follow the detected runtime platform above for shell syntax and filesystem path style.
|
||||||
|
- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS).
|
||||||
|
- On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\<name>\\Desktop\`).
|
||||||
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
|
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
|
||||||
- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`.
|
- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`.
|
||||||
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
|
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
|
||||||
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
|
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
|
||||||
- NEVER ask what OS the user is on - they are on macOS.
|
- NEVER ask what OS the user is on if runtime platform is already available.
|
||||||
- Load the \`organize-files\` skill for guidance on file organization tasks.
|
- Load the \`organize-files\` skill for guidance on file organization tasks.
|
||||||
|
|
||||||
## Builtin Tools vs Shell Commands
|
## Builtin Tools vs Shell Commands
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh';
|
||||||
|
export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
|
||||||
|
|
||||||
|
export interface RuntimeContext {
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
osName: RuntimeOsName;
|
||||||
|
shellDialect: RuntimeShellDialect;
|
||||||
|
shellExecutable: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
|
||||||
|
return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext {
|
||||||
|
if (platform === 'win32') {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'Windows',
|
||||||
|
shellDialect: 'windows-cmd',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'macOS',
|
||||||
|
shellDialect: 'posix-sh',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'linux') {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'Linux',
|
||||||
|
shellDialect: 'posix-sh',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'Unknown',
|
||||||
|
shellDialect: 'posix-sh',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeContextPrompt(runtime: RuntimeContext): string {
|
||||||
|
if (runtime.shellDialect === 'windows-cmd') {
|
||||||
|
return `## Runtime Platform (CRITICAL)
|
||||||
|
- Detected platform: **${runtime.platform}**
|
||||||
|
- Detected OS: **${runtime.osName}**
|
||||||
|
- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax)
|
||||||
|
- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`).
|
||||||
|
- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`).
|
||||||
|
- Do not assume macOS/Linux command syntax when the runtime is Windows.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `## Runtime Platform (CRITICAL)
|
||||||
|
- Detected platform: **${runtime.platform}**
|
||||||
|
- Detected OS: **${runtime.osName}**
|
||||||
|
- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax)
|
||||||
|
- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`).
|
||||||
|
- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux).
|
||||||
|
- Do not assume Windows command syntax when the runtime is POSIX.`;
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,14 @@
|
||||||
import { exec, execSync, spawn, ChildProcess } from 'child_process';
|
import { exec, execSync, spawn, ChildProcess } from 'child_process';
|
||||||
import { existsSync } from 'fs';
|
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { getSecurityAllowList } from '../../config/security.js';
|
import { getSecurityAllowList } from '../../config/security.js';
|
||||||
|
import { getExecutionShell } from '../assistant/runtime-context.js';
|
||||||
|
|
||||||
const execPromise = promisify(exec);
|
const execPromise = promisify(exec);
|
||||||
|
|
||||||
function getShell(): string {
|
|
||||||
if (process.platform !== 'win32') return '/bin/sh';
|
|
||||||
// On Windows, try Git Bash first, then fall back to cmd.exe
|
|
||||||
const gitBashPaths = [
|
|
||||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
||||||
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
||||||
];
|
|
||||||
for (const p of gitBashPaths) {
|
|
||||||
if (existsSync(p)) return p;
|
|
||||||
}
|
|
||||||
return 'cmd.exe';
|
|
||||||
}
|
|
||||||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
|
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
|
||||||
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
||||||
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
||||||
|
const EXECUTION_SHELL = getExecutionShell();
|
||||||
|
|
||||||
function sanitizeToken(token: string): string {
|
function sanitizeToken(token: string): string {
|
||||||
return token.trim().replace(/^['"()]+|['"()]+$/g, '');
|
return token.trim().replace(/^['"()]+|['"()]+$/g, '');
|
||||||
|
|
@ -99,7 +88,7 @@ export async function executeCommand(
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
||||||
shell: getShell(), // use sh for cross-platform compatibility
|
shell: EXECUTION_SHELL,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -159,7 +148,7 @@ export function executeCommandAbortable(
|
||||||
// Check if already aborted before spawning
|
// Check if already aborted before spawning
|
||||||
if (options?.signal?.aborted) {
|
if (options?.signal?.aborted) {
|
||||||
// Return a dummy process and a resolved result
|
// Return a dummy process and a resolved result
|
||||||
const dummyProc = spawn('true', { shell: true });
|
const dummyProc = spawn(process.execPath, ['-e', 'process.exit(0)']);
|
||||||
dummyProc.kill();
|
dummyProc.kill();
|
||||||
return {
|
return {
|
||||||
process: dummyProc,
|
process: dummyProc,
|
||||||
|
|
@ -173,7 +162,7 @@ export function executeCommandAbortable(
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = spawn(command, [], {
|
const proc = spawn(command, [], {
|
||||||
shell: getShell(),
|
shell: EXECUTION_SHELL,
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
detached: process.platform !== 'win32', // Create process group on Unix
|
detached: process.platform !== 'win32', // Create process group on Unix
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
|
@ -287,7 +276,11 @@ export function executeCommandSync(
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
|
<<<<<<< HEAD
|
||||||
shell: getShell(),
|
shell: getShell(),
|
||||||
|
=======
|
||||||
|
shell: EXECUTION_SHELL,
|
||||||
|
>>>>>>> dev
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
|
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
|
||||||
|
import { UserMessageContent } from "@x/shared/dist/message.js";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export type UserMessageContentType = z.infer<typeof UserMessageContent>;
|
||||||
|
|
||||||
type EnqueuedMessage = {
|
type EnqueuedMessage = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
message: string;
|
message: UserMessageContentType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IMessageQueue {
|
export interface IMessageQueue {
|
||||||
enqueue(runId: string, message: string): Promise<string>;
|
enqueue(runId: string, message: UserMessageContentType): Promise<string>;
|
||||||
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
||||||
this.idGenerator = idGenerator;
|
this.idGenerator = idGenerator;
|
||||||
}
|
}
|
||||||
|
|
||||||
async enqueue(runId: string, message: string): Promise<string> {
|
async enqueue(runId: string, message: UserMessageContentType): Promise<string> {
|
||||||
if (!this.store[runId]) {
|
if (!this.store[runId]) {
|
||||||
this.store[runId] = [];
|
this.store[runId] = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ function ensureDefaultConfigs() {
|
||||||
const noteCreationConfig = path.join(WorkDir, "config", "note_creation.json");
|
const noteCreationConfig = path.join(WorkDir, "config", "note_creation.json");
|
||||||
if (!fs.existsSync(noteCreationConfig)) {
|
if (!fs.existsSync(noteCreationConfig)) {
|
||||||
fs.writeFileSync(noteCreationConfig, JSON.stringify({
|
fs.writeFileSync(noteCreationConfig, JSON.stringify({
|
||||||
strictness: "high",
|
strictness: "medium",
|
||||||
configured: false
|
configured: false
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
}
|
}
|
||||||
|
|
@ -91,4 +91,9 @@ function ensureWelcomeFile() {
|
||||||
|
|
||||||
ensureDirs();
|
ensureDirs();
|
||||||
ensureDefaultConfigs();
|
ensureDefaultConfigs();
|
||||||
ensureWelcomeFile();
|
ensureWelcomeFile();
|
||||||
|
|
||||||
|
// Initialize version history repo (async, fire-and-forget on startup)
|
||||||
|
import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => {
|
||||||
|
console.error('[VersionHistory] Failed to init repo:', err);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ interface NoteCreationConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_FILE = path.join(WorkDir, 'config', 'note_creation.json');
|
const CONFIG_FILE = path.join(WorkDir, 'config', 'note_creation.json');
|
||||||
const DEFAULT_STRICTNESS: NoteCreationStrictness = 'high';
|
const DEFAULT_STRICTNESS: NoteCreationStrictness = 'medium';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the full config file.
|
* Read the full config file.
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,7 @@ export * as workspace from './workspace/workspace.js';
|
||||||
export * as watcher from './workspace/watcher.js';
|
export * as watcher from './workspace/watcher.js';
|
||||||
|
|
||||||
// Config initialization
|
// Config initialization
|
||||||
export { initConfigs } from './config/initConfigs.js';
|
export { initConfigs } from './config/initConfigs.js';
|
||||||
|
|
||||||
|
// Knowledge version history
|
||||||
|
export * as versionHistory from './knowledge/version_history.js';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
import { autoConfigureStrictnessIfNeeded } from '../config/strictness_analyzer.js';
|
|
||||||
import { createRun, createMessage } from '../runs/runs.js';
|
import { createRun, createMessage } from '../runs/runs.js';
|
||||||
import { bus } from '../runs/bus.js';
|
import { bus } from '../runs/bus.js';
|
||||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||||
|
|
@ -15,6 +14,7 @@ import {
|
||||||
} from './graph_state.js';
|
} from './graph_state.js';
|
||||||
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
|
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
|
||||||
import { limitEventItems } from './limit_event_items.js';
|
import { limitEventItems } from './limit_event_items.js';
|
||||||
|
import { commitAll } from './version_history.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build obsidian-style knowledge graph by running topic extraction
|
* Build obsidian-style knowledge graph by running topic extraction
|
||||||
|
|
@ -320,6 +320,13 @@ async function buildGraphWithFiles(
|
||||||
// Save state after each successful batch
|
// Save state after each successful batch
|
||||||
// This ensures partial progress is saved even if later batches fail
|
// This ensures partial progress is saved even if later batches fail
|
||||||
saveState(state);
|
saveState(state);
|
||||||
|
|
||||||
|
// Commit knowledge changes to version history
|
||||||
|
try {
|
||||||
|
await commitAll('Knowledge update', 'Rowboat');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[GraphBuilder] Failed to commit version history:`, err);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hadError = true;
|
hadError = true;
|
||||||
console.error(`Error processing batch ${batchNumber}:`, error);
|
console.error(`Error processing batch ${batchNumber}:`, error);
|
||||||
|
|
@ -355,7 +362,19 @@ export async function buildGraph(sourceDir: string): Promise<void> {
|
||||||
console.log(`[buildGraph] State loaded. Previously processed: ${previouslyProcessedCount} files`);
|
console.log(`[buildGraph] State loaded. Previously processed: ${previouslyProcessedCount} files`);
|
||||||
|
|
||||||
// Get files that need processing (new or changed)
|
// Get files that need processing (new or changed)
|
||||||
const filesToProcess = getFilesToProcess(sourceDir, state);
|
let filesToProcess = getFilesToProcess(sourceDir, state);
|
||||||
|
|
||||||
|
// For gmail_sync, only process emails that have been labeled (have YAML frontmatter)
|
||||||
|
if (sourceDir.endsWith('gmail_sync')) {
|
||||||
|
filesToProcess = filesToProcess.filter(filePath => {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return content.startsWith('---');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (filesToProcess.length === 0) {
|
if (filesToProcess.length === 0) {
|
||||||
console.log(`[buildGraph] No new or changed files to process in ${path.basename(sourceDir)}`);
|
console.log(`[buildGraph] No new or changed files to process in ${path.basename(sourceDir)}`);
|
||||||
|
|
@ -467,6 +486,13 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
|
||||||
|
|
||||||
// Save state after each batch
|
// Save state after each batch
|
||||||
saveState(state);
|
saveState(state);
|
||||||
|
|
||||||
|
// Commit knowledge changes to version history
|
||||||
|
try {
|
||||||
|
await commitAll('Knowledge update', 'Rowboat');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[GraphBuilder] Failed to commit version history:`, err);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hadError = true;
|
hadError = true;
|
||||||
console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error);
|
console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error);
|
||||||
|
|
@ -510,8 +536,6 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
|
||||||
async function processAllSources(): Promise<void> {
|
async function processAllSources(): Promise<void> {
|
||||||
console.log('[GraphBuilder] Checking for new content in all sources...');
|
console.log('[GraphBuilder] Checking for new content in all sources...');
|
||||||
|
|
||||||
// Auto-configure strictness on first run if not already done
|
|
||||||
autoConfigureStrictnessIfNeeded();
|
|
||||||
|
|
||||||
let anyFilesProcessed = false;
|
let anyFilesProcessed = false;
|
||||||
|
|
||||||
|
|
@ -540,7 +564,19 @@ async function processAllSources(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filesToProcess = getFilesToProcess(sourceDir, state);
|
let filesToProcess = getFilesToProcess(sourceDir, state);
|
||||||
|
|
||||||
|
// For gmail_sync, only process emails that have been labeled (have YAML frontmatter)
|
||||||
|
if (folder === 'gmail_sync') {
|
||||||
|
filesToProcess = filesToProcess.filter(filePath => {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return content.startsWith('---');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (filesToProcess.length > 0) {
|
if (filesToProcess.length > 0) {
|
||||||
console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${folder}`);
|
console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${folder}`);
|
||||||
|
|
|
||||||
27
apps/x/packages/core/src/knowledge/inline_task_agent.ts
Normal file
27
apps/x/packages/core/src/knowledge/inline_task_agent.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { BuiltinTools } from '../application/lib/builtin-tools.js';
|
||||||
|
|
||||||
|
export function getRaw(): string {
|
||||||
|
const toolEntries = Object.keys(BuiltinTools)
|
||||||
|
.map(name => ` ${name}:\n type: builtin\n name: ${name}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `---
|
||||||
|
model: gpt-5.2
|
||||||
|
tools:
|
||||||
|
${toolEntries}
|
||||||
|
---
|
||||||
|
# Task
|
||||||
|
|
||||||
|
You are an inline task execution agent. You receive a @rowboat instruction from within a knowledge note and execute it.
|
||||||
|
|
||||||
|
# Instructions
|
||||||
|
|
||||||
|
1. You will receive the full content of a knowledge note and a specific instruction extracted from a \`@rowboat <instruction>\` line in that note.
|
||||||
|
2. Execute the instruction using your full workspace tool set. You have access to read files, edit files, search, run commands, etc.
|
||||||
|
3. Use the surrounding note content as context for the task.
|
||||||
|
4. Your response will be inserted directly into the note below the @rowboat instruction. Write your output as note content — it must read naturally as part of the document.
|
||||||
|
5. NEVER include meta-commentary, thinking out loud, or narration about what you're doing. No "Let me look that up", "Here are the details", "I found the following", etc. Just write the content itself.
|
||||||
|
6. Keep the result concise and well-formatted in markdown.
|
||||||
|
7. Do not modify the original note file — the service will handle inserting your response.
|
||||||
|
`;
|
||||||
|
}
|
||||||
626
apps/x/packages/core/src/knowledge/inline_tasks.ts
Normal file
626
apps/x/packages/core/src/knowledge/inline_tasks.ts
Normal file
|
|
@ -0,0 +1,626 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
|
import { generateText } from 'ai';
|
||||||
|
import { WorkDir } from '../config/config.js';
|
||||||
|
import { createRun, createMessage, fetchRun } from '../runs/runs.js';
|
||||||
|
import { bus } from '../runs/bus.js';
|
||||||
|
import container from '../di/container.js';
|
||||||
|
import type { IModelConfigRepo } from '../models/repo.js';
|
||||||
|
import { createProvider } from '../models/models.js';
|
||||||
|
import { inlineTask } from '@x/shared';
|
||||||
|
|
||||||
|
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
||||||
|
const INLINE_TASK_AGENT = 'inline_task_agent';
|
||||||
|
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Minimal frontmatter helpers (duplicated from renderer to avoid cross-package
|
||||||
|
// dependency — can be moved to shared later).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function splitFrontmatter(content: string): { raw: string | null; body: string } {
|
||||||
|
if (!content.startsWith('---')) {
|
||||||
|
return { raw: null, body: content };
|
||||||
|
}
|
||||||
|
const endIndex = content.indexOf('\n---', 3);
|
||||||
|
if (endIndex === -1) {
|
||||||
|
return { raw: null, body: content };
|
||||||
|
}
|
||||||
|
const closingEnd = endIndex + 4;
|
||||||
|
const raw = content.slice(0, closingEnd);
|
||||||
|
let body = content.slice(closingEnd);
|
||||||
|
if (body.startsWith('\n')) {
|
||||||
|
body = body.slice(1);
|
||||||
|
}
|
||||||
|
return { raw, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinFrontmatter(raw: string | null, body: string): string {
|
||||||
|
if (!raw) return body;
|
||||||
|
return raw + '\n' + body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
|
||||||
|
const result: Record<string, string | string[]> = {};
|
||||||
|
if (!raw) return result;
|
||||||
|
|
||||||
|
const lines = raw.split('\n');
|
||||||
|
let currentKey: string | null = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line === '---' || line.trim() === '') {
|
||||||
|
currentKey = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const topMatch = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/);
|
||||||
|
if (topMatch) {
|
||||||
|
const key = topMatch[1];
|
||||||
|
const value = topMatch[2].trim();
|
||||||
|
if (value) {
|
||||||
|
result[key] = value;
|
||||||
|
currentKey = null;
|
||||||
|
} else {
|
||||||
|
currentKey = key;
|
||||||
|
result[key] = [];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (currentKey) {
|
||||||
|
const itemMatch = line.match(/^\s+-\s+(.+)$/);
|
||||||
|
if (itemMatch) {
|
||||||
|
const arr = result[currentKey];
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
arr.push(itemMatch[1].trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) continue;
|
||||||
|
lines.push(`${key}:`);
|
||||||
|
for (const item of value) {
|
||||||
|
if (item.trim()) lines.push(` - ${item.trim()}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const trimmed = (value ?? '').trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
lines.push(`${key}: ${trimmed}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lines.length === 0) return null;
|
||||||
|
return `---\n${lines.join('\n')}\n---`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schedule types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type InlineTaskSchedule =
|
||||||
|
| { type: 'cron'; expression: string; startDate: string; endDate: string; label: string }
|
||||||
|
| { type: 'window'; cron: string; startTime: string; endTime: string; startDate: string; endDate: string; label: string }
|
||||||
|
| { type: 'once'; runAt: string; label: string };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function scanDirectoryRecursive(dir: string): string[] {
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
const files: string[] = [];
|
||||||
|
const entries = fs.readdirSync(dir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.startsWith('.')) continue;
|
||||||
|
const fullPath = path.join(dir, entry);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
files.push(...scanDirectoryRecursive(fullPath));
|
||||||
|
} else if (stat.isFile() && entry.endsWith('.md')) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a run to complete by listening for run-processing-end event
|
||||||
|
*/
|
||||||
|
async function waitForRunCompletion(runId: string): Promise<void> {
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
const unsubscribe = await bus.subscribe('*', async (event) => {
|
||||||
|
if (event.type === 'run-processing-end' && event.runId === runId) {
|
||||||
|
unsubscribe();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the assistant's final text response from a run's log.
|
||||||
|
*/
|
||||||
|
async function extractAgentResponse(runId: string): Promise<string | null> {
|
||||||
|
const run = await fetchRun(runId);
|
||||||
|
// Walk backwards through the log to find the last assistant message
|
||||||
|
for (let i = run.log.length - 1; i >= 0; i--) {
|
||||||
|
const event = run.log[i];
|
||||||
|
if (event.type === 'message' && event.message.role === 'assistant') {
|
||||||
|
const content = event.message.content;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
// Content may be an array of parts — concatenate text parts
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const text = content
|
||||||
|
.filter((p) => p.type === 'text')
|
||||||
|
.map((p) => (p as { type: 'text'; text: string }).text)
|
||||||
|
.join('');
|
||||||
|
return text || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InlineTask {
|
||||||
|
instruction: string;
|
||||||
|
schedule: InlineTaskSchedule | null;
|
||||||
|
/** Line index of the opening ```task fence in the body */
|
||||||
|
startLine: number;
|
||||||
|
/** Line index of the closing ``` fence */
|
||||||
|
endLine: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the tell-rowboat block content (JSON format).
|
||||||
|
* Returns { instruction, schedule } or null if not valid JSON.
|
||||||
|
* Also supports legacy @rowboat format.
|
||||||
|
*/
|
||||||
|
function parseBlockContent(contentLines: string[]): { instruction: string; schedule: InlineTaskSchedule | null; lastRunAt: string | null } | null {
|
||||||
|
const raw = contentLines.join('\n').trim();
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
const parsed = inlineTask.InlineTaskBlockSchema.safeParse(data);
|
||||||
|
if (parsed.success) {
|
||||||
|
return {
|
||||||
|
instruction: parsed.data.instruction,
|
||||||
|
schedule: parsed.data.schedule ? { ...parsed.data.schedule, label: parsed.data['schedule-label'] ?? '' } as InlineTaskSchedule : null,
|
||||||
|
lastRunAt: parsed.data.lastRunAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Fallback for blocks that have instruction but don't fully match schema
|
||||||
|
if (data && typeof data === 'object' && data.instruction) {
|
||||||
|
return {
|
||||||
|
instruction: data.instruction,
|
||||||
|
schedule: data.schedule ?? null,
|
||||||
|
lastRunAt: data.lastRunAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Legacy format: @rowboat lines + optional schedule: JSON line
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback: parse @rowboat instruction and schedule: line
|
||||||
|
let schedule: InlineTaskSchedule | null = null;
|
||||||
|
const instructionLines: string[] = [];
|
||||||
|
for (const cl of contentLines) {
|
||||||
|
const schedMatch = cl.trim().match(/^schedule:\s*(.+)$/);
|
||||||
|
if (schedMatch) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(schedMatch[1]);
|
||||||
|
if (obj && typeof obj === 'object' && obj.type) {
|
||||||
|
schedule = obj as InlineTaskSchedule;
|
||||||
|
}
|
||||||
|
} catch { /* not JSON schedule, skip */ }
|
||||||
|
} else if (!/^schedule-config:\s/.test(cl.trim())) {
|
||||||
|
instructionLines.push(cl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const firstRowboatLine = instructionLines.find(l => l.trim().startsWith('@rowboat'));
|
||||||
|
const rawInstruction = firstRowboatLine?.trim() ?? instructionLines.join('\n').trim();
|
||||||
|
const instruction = rawInstruction.replace(/^@rowboat:?\s*/, '');
|
||||||
|
if (!instruction) return null;
|
||||||
|
return { instruction, schedule, lastRunAt: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a scheduled task is due to run.
|
||||||
|
*/
|
||||||
|
function isScheduledTaskDue(schedule: InlineTaskSchedule, lastRunAt: string | null): boolean {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Check startDate/endDate bounds for cron and window
|
||||||
|
if (schedule.type === 'cron' || schedule.type === 'window') {
|
||||||
|
if (schedule.startDate && now < new Date(schedule.startDate)) return false;
|
||||||
|
if (schedule.endDate && now > new Date(schedule.endDate)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (schedule.type) {
|
||||||
|
case 'cron': {
|
||||||
|
if (!lastRunAt) return true; // Never run → due
|
||||||
|
try {
|
||||||
|
const lastRun = new Date(lastRunAt);
|
||||||
|
const interval = CronExpressionParser.parse(schedule.expression, {
|
||||||
|
currentDate: lastRun,
|
||||||
|
});
|
||||||
|
const nextRun = interval.next().toDate();
|
||||||
|
return now >= nextRun;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'window': {
|
||||||
|
if (!lastRunAt) return true;
|
||||||
|
try {
|
||||||
|
const lastRun = new Date(lastRunAt);
|
||||||
|
const interval = CronExpressionParser.parse(schedule.cron, {
|
||||||
|
currentDate: lastRun,
|
||||||
|
});
|
||||||
|
const nextDate = interval.next().toDate();
|
||||||
|
|
||||||
|
// Check if we're within the time window
|
||||||
|
const [startHour, startMin] = schedule.startTime.split(':').map(Number);
|
||||||
|
const [endHour, endMin] = schedule.endTime.split(':').map(Number);
|
||||||
|
const startMinutes = startHour * 60 + startMin;
|
||||||
|
const endMinutes = endHour * 60 + endMin;
|
||||||
|
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||||
|
|
||||||
|
// The cron date must have passed and we need to be in the time window
|
||||||
|
return now >= nextDate && nowMinutes >= startMinutes && nowMinutes <= endMinutes;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'once': {
|
||||||
|
if (lastRunAt) return false; // Already ran
|
||||||
|
const runAt = new Date(schedule.runAt);
|
||||||
|
return now >= runAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find ```tell-rowboat code blocks in a note body and return tasks that are pending execution.
|
||||||
|
*/
|
||||||
|
function findPendingTasks(body: string): InlineTask[] {
|
||||||
|
const tasks: InlineTask[] = [];
|
||||||
|
const lines = body.split('\n');
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const trimmed = lines[i].trim();
|
||||||
|
if (trimmed.startsWith('```task') || trimmed.startsWith('```tell-rowboat')) {
|
||||||
|
const startLine = i;
|
||||||
|
i++;
|
||||||
|
const contentLines: string[] = [];
|
||||||
|
while (i < lines.length && lines[i].trim() !== '```') {
|
||||||
|
contentLines.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const endLine = i; // line with closing ```
|
||||||
|
|
||||||
|
const parsed = parseBlockContent(contentLines);
|
||||||
|
if (parsed) {
|
||||||
|
const { instruction, schedule, lastRunAt } = parsed;
|
||||||
|
|
||||||
|
if (schedule) {
|
||||||
|
if (isScheduledTaskDue(schedule, lastRunAt)) {
|
||||||
|
tasks.push({ instruction, schedule, startLine, endLine });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// One-time task: skip if already ran
|
||||||
|
if (!lastRunAt) {
|
||||||
|
tasks.push({ instruction, schedule: null, startLine, endLine });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert the agent result below the tell-rowboat code block in the body.
|
||||||
|
* Returns the updated body string.
|
||||||
|
*/
|
||||||
|
function insertResultBelow(body: string, endLine: number, result: string): string {
|
||||||
|
const lines = body.split('\n');
|
||||||
|
// Insert a blank line + result after the closing ``` fence
|
||||||
|
lines.splice(endLine + 1, 0, '', result);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a note has any "live" tell-rowboat tasks.
|
||||||
|
* A task is live if:
|
||||||
|
* - It's a one-time task that hasn't been completed yet
|
||||||
|
* - It's a scheduled task whose endDate hasn't passed (or has no endDate)
|
||||||
|
* - It's a scheduled task before its startDate (will run in the future)
|
||||||
|
*/
|
||||||
|
function hasLiveTasks(body: string): boolean {
|
||||||
|
const now = new Date();
|
||||||
|
const lines = body.split('\n');
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const trimmed = lines[i].trim();
|
||||||
|
if (trimmed.startsWith('```task') || trimmed.startsWith('```tell-rowboat')) {
|
||||||
|
i++;
|
||||||
|
const contentLines: string[] = [];
|
||||||
|
while (i < lines.length && lines[i].trim() !== '```') {
|
||||||
|
contentLines.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseBlockContent(contentLines);
|
||||||
|
if (!parsed) { i++; continue; }
|
||||||
|
|
||||||
|
const { schedule, lastRunAt } = parsed;
|
||||||
|
|
||||||
|
if (schedule) {
|
||||||
|
if (schedule.type === 'cron' || schedule.type === 'window') {
|
||||||
|
const endDate = schedule.endDate;
|
||||||
|
if (!endDate || now <= new Date(endDate)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (schedule.type === 'once') {
|
||||||
|
if (!lastRunAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// One-time task without schedule: live if never ran
|
||||||
|
if (!lastRunAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Block data helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the JSON content inside a task code block to include lastRunAt.
|
||||||
|
* Replaces the content lines between the opening and closing fences.
|
||||||
|
*/
|
||||||
|
function updateBlockData(body: string, startLine: number, endLine: number, lastRunAt: string): string {
|
||||||
|
const lines = body.split('\n');
|
||||||
|
// Content is between startLine+1 and endLine-1
|
||||||
|
const contentLines = lines.slice(startLine + 1, endLine);
|
||||||
|
const raw = contentLines.join('\n').trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
data.lastRunAt = lastRunAt;
|
||||||
|
const updatedJson = JSON.stringify(data);
|
||||||
|
// Replace content lines with the updated JSON (single line)
|
||||||
|
lines.splice(startLine + 1, endLine - startLine - 1, updatedJson);
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON — skip update
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main processing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function processInlineTasks(): Promise<void> {
|
||||||
|
console.log('[InlineTasks] Checking live notes...');
|
||||||
|
|
||||||
|
if (!fs.existsSync(KNOWLEDGE_DIR)) {
|
||||||
|
console.log('[InlineTasks] Knowledge directory not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFiles = scanDirectoryRecursive(KNOWLEDGE_DIR);
|
||||||
|
let totalProcessed = 0;
|
||||||
|
|
||||||
|
for (const filePath of allFiles) {
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { raw, body } = splitFrontmatter(content);
|
||||||
|
const fields = extractAllFrontmatterValues(raw);
|
||||||
|
|
||||||
|
// Only process files marked as live
|
||||||
|
if (fields['live_note'] !== 'true') continue;
|
||||||
|
|
||||||
|
const tasks = findPendingTasks(body);
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
// No pending tasks — check if still live, update if not
|
||||||
|
const live = hasLiveTasks(body);
|
||||||
|
if (!live) {
|
||||||
|
fields['live_note'] = 'false';
|
||||||
|
// Remove rowboat_tasks if present (legacy cleanup)
|
||||||
|
delete fields['rowboat_tasks'];
|
||||||
|
const newRaw = buildFrontmatter(fields);
|
||||||
|
const newContent = joinFrontmatter(newRaw, body);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, newContent, 'utf-8');
|
||||||
|
const rel = path.relative(WorkDir, filePath);
|
||||||
|
console.log(`[InlineTasks] Marked ${rel} as no longer live`);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = path.relative(WorkDir, filePath);
|
||||||
|
console.log(`[InlineTasks] Found ${tasks.length} pending task(s) in ${relativePath}`);
|
||||||
|
|
||||||
|
// Process tasks one at a time, bottom-up so line indices stay valid
|
||||||
|
// (inserting content shifts lines below, so process from bottom to top)
|
||||||
|
const sortedTasks = [...tasks].sort((a, b) => b.endLine - a.endLine);
|
||||||
|
|
||||||
|
let currentBody = body;
|
||||||
|
|
||||||
|
for (const task of sortedTasks) {
|
||||||
|
console.log(`[InlineTasks] Running task: "${task.instruction.slice(0, 80)}..."`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const run = await createRun({ agentId: INLINE_TASK_AGENT });
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
`Execute the following instruction from the note "${relativePath}":`,
|
||||||
|
'',
|
||||||
|
`**Instruction:** ${task.instruction}`,
|
||||||
|
'',
|
||||||
|
'**Full note content for context:**',
|
||||||
|
'```markdown',
|
||||||
|
content,
|
||||||
|
'```',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
await createMessage(run.id, message);
|
||||||
|
await waitForRunCompletion(run.id);
|
||||||
|
|
||||||
|
const result = await extractAgentResponse(run.id);
|
||||||
|
if (result) {
|
||||||
|
currentBody = insertResultBelow(currentBody, task.endLine, result);
|
||||||
|
// Update the block JSON with lastRunAt
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
currentBody = updateBlockData(currentBody, task.startLine, task.endLine, timestamp);
|
||||||
|
totalProcessed++;
|
||||||
|
console.log(`[InlineTasks] Task completed`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[InlineTasks] No response from agent for task`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[InlineTasks] Error processing task:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update frontmatter — only manage live_note, remove legacy rowboat_tasks
|
||||||
|
const live = hasLiveTasks(currentBody);
|
||||||
|
fields['live_note'] = live ? 'true' : 'false';
|
||||||
|
delete fields['rowboat_tasks'];
|
||||||
|
const newRaw = buildFrontmatter(fields);
|
||||||
|
const newContent = joinFrontmatter(newRaw, currentBody);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, newContent, 'utf-8');
|
||||||
|
console.log(`[InlineTasks] Updated ${relativePath}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[InlineTasks] Error writing ${relativePath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalProcessed > 0) {
|
||||||
|
console.log(`[InlineTasks] Done. Processed ${totalProcessed} task(s).`);
|
||||||
|
} else {
|
||||||
|
console.log('[InlineTasks] No pending tasks found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify whether an instruction contains a scheduling intent using the user's configured LLM.
|
||||||
|
* Returns a schedule object or null for one-time tasks.
|
||||||
|
*/
|
||||||
|
export async function classifySchedule(instruction: string): Promise<InlineTaskSchedule | null> {
|
||||||
|
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||||
|
const config = await repo.getConfig();
|
||||||
|
const provider = createProvider(config.provider);
|
||||||
|
const model = provider.languageModel(config.model);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const defaultEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||||
|
const localEnd = defaultEnd.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||||
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const nowISO = now.toISOString();
|
||||||
|
const defaultEndISO = defaultEnd.toISOString();
|
||||||
|
|
||||||
|
const systemPrompt = `You classify whether a user instruction contains a scheduling intent.
|
||||||
|
|
||||||
|
If the instruction implies a recurring or future-scheduled task, return a JSON object with the schedule.
|
||||||
|
If the instruction is a one-time immediate task, return null.
|
||||||
|
|
||||||
|
Every schedule object MUST include a "label" field: a short, plain-English description starting with "runs" that includes the end date (e.g. "runs every 2 minutes until Mar 12", "runs daily at 8 AM until Mar 12").
|
||||||
|
|
||||||
|
Schedule types:
|
||||||
|
1. "cron" — recurring schedule. Return: {"type":"cron","expression":"<cron>","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}
|
||||||
|
Use standard 5-field cron (minute hour day-of-month month day-of-week).
|
||||||
|
"startDate" defaults to now (${nowISO}). "endDate" defaults to 7 days from now (${defaultEndISO}).
|
||||||
|
Override these if the user specifies a duration (e.g. "for the next 3 days" → endDate = now + 3 days) or a start (e.g. "starting next Monday").
|
||||||
|
Example: "every morning at 8am" → {"type":"cron","expression":"0 8 * * *","startDate":"${nowISO}","endDate":"${defaultEndISO}","label":"runs daily at 8 AM until Mar 12"}
|
||||||
|
|
||||||
|
2. "window" — recurring with a time window. Return: {"type":"window","cron":"<cron>","startTime":"HH:MM","endTime":"HH:MM","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}
|
||||||
|
Use when the user specifies a range like "between 8am and 10am". Same startDate/endDate defaults and override rules as cron.
|
||||||
|
|
||||||
|
3. "once" — run once at a specific future time. Return: {"type":"once","runAt":"<ISO 8601 datetime>","label":"<human readable>"}
|
||||||
|
Use when the user says "tomorrow at 3pm", "next Friday", etc. No startDate/endDate for once.
|
||||||
|
|
||||||
|
Current local time: ${localNow}
|
||||||
|
Timezone: ${tz}
|
||||||
|
Current UTC time: ${nowISO}
|
||||||
|
Default end time (local): ${localEnd}
|
||||||
|
|
||||||
|
Respond with ONLY valid JSON: either a schedule object or null. No other text.`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await generateText({
|
||||||
|
model,
|
||||||
|
system: systemPrompt,
|
||||||
|
prompt: instruction,
|
||||||
|
});
|
||||||
|
|
||||||
|
let text = result.text.trim();
|
||||||
|
console.log('[classifySchedule] LLM response:', text);
|
||||||
|
// Strip markdown code fences if the LLM wraps the JSON
|
||||||
|
text = text.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
|
||||||
|
if (text === 'null' || text === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || !parsed.type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as InlineTaskSchedule;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[classifySchedule] Error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point — runs as independent polling service
|
||||||
|
*/
|
||||||
|
export async function init() {
|
||||||
|
console.log('[InlineTasks] Starting Inline Task Service...');
|
||||||
|
console.log(`[InlineTasks] Will check for task blocks every ${SYNC_INTERVAL_MS / 1000} seconds`);
|
||||||
|
|
||||||
|
// Initial run
|
||||||
|
await processInlineTasks();
|
||||||
|
|
||||||
|
// Periodic polling
|
||||||
|
while (true) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await processInlineTasks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[InlineTasks] Error in main loop:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
269
apps/x/packages/core/src/knowledge/label_emails.ts
Normal file
269
apps/x/packages/core/src/knowledge/label_emails.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { WorkDir } from '../config/config.js';
|
||||||
|
import { createRun, createMessage } from '../runs/runs.js';
|
||||||
|
import { bus } from '../runs/bus.js';
|
||||||
|
import { serviceLogger } from '../services/service_logger.js';
|
||||||
|
import { limitEventItems } from './limit_event_items.js';
|
||||||
|
import {
|
||||||
|
loadLabelingState,
|
||||||
|
saveLabelingState,
|
||||||
|
markFileAsLabeled,
|
||||||
|
type LabelingState,
|
||||||
|
} from './labeling_state.js';
|
||||||
|
|
||||||
|
const SYNC_INTERVAL_MS = 3 * 60 * 1000; // 3 minutes
|
||||||
|
const BATCH_SIZE = 15;
|
||||||
|
const LABELING_AGENT = 'labeling_agent';
|
||||||
|
const GMAIL_SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
||||||
|
const MAX_CONTENT_LENGTH = 8000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find email files that haven't been labeled yet
|
||||||
|
*/
|
||||||
|
function getUnlabeledEmails(state: LabelingState): string[] {
|
||||||
|
if (!fs.existsSync(GMAIL_SYNC_DIR)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlabeled: string[] = [];
|
||||||
|
|
||||||
|
function traverse(dir: string) {
|
||||||
|
const entries = fs.readdirSync(dir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
traverse(fullPath);
|
||||||
|
} else if (stat.isFile() && entry.endsWith('.md')) {
|
||||||
|
// Skip if already tracked in state
|
||||||
|
if (state.processedFiles[fullPath]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if file already has frontmatter
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||||
|
if (content.startsWith('---')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unlabeled.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(GMAIL_SYNC_DIR);
|
||||||
|
return unlabeled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a run to complete by listening for run-processing-end event
|
||||||
|
*/
|
||||||
|
async function waitForRunCompletion(runId: string): Promise<void> {
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
const unsubscribe = await bus.subscribe('*', async (event) => {
|
||||||
|
if (event.type === 'run-processing-end' && event.runId === runId) {
|
||||||
|
unsubscribe();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label a batch of email files using the labeling agent
|
||||||
|
*/
|
||||||
|
async function labelEmailBatch(
|
||||||
|
files: { path: string; content: string }[]
|
||||||
|
): Promise<{ runId: string; filesEdited: Set<string> }> {
|
||||||
|
const run = await createRun({
|
||||||
|
agentId: LABELING_AGENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`;
|
||||||
|
message += `**Important:** Use workspace-relative paths with workspace-edit (e.g. "gmail_sync/email.md", NOT absolute paths).\n\n`;
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
const relativePath = path.relative(WorkDir, file.path);
|
||||||
|
const truncated = file.content.length > MAX_CONTENT_LENGTH
|
||||||
|
? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use workspace-readFile for full content ...]'
|
||||||
|
: file.content;
|
||||||
|
|
||||||
|
message += `## File ${i + 1}: ${relativePath}\n\n`;
|
||||||
|
message += truncated;
|
||||||
|
message += `\n\n---\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesEdited = new Set<string>();
|
||||||
|
|
||||||
|
const unsubscribe = await bus.subscribe(run.id, async (event) => {
|
||||||
|
if (event.type !== 'tool-invocation') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.toolName !== 'workspace-edit') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.input) as { path?: string };
|
||||||
|
if (typeof parsed.path === 'string') {
|
||||||
|
filesEdited.add(parsed.path);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMessage(run.id, message);
|
||||||
|
await waitForRunCompletion(run.id);
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
return { runId: run.id, filesEdited };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all unlabeled emails in batches
|
||||||
|
*/
|
||||||
|
async function processUnlabeledEmails(): Promise<void> {
|
||||||
|
console.log('[EmailLabeling] Checking for unlabeled emails...');
|
||||||
|
|
||||||
|
const state = loadLabelingState();
|
||||||
|
const unlabeled = getUnlabeledEmails(state);
|
||||||
|
|
||||||
|
if (unlabeled.length === 0) {
|
||||||
|
console.log('[EmailLabeling] No unlabeled emails found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[EmailLabeling] Found ${unlabeled.length} unlabeled emails`);
|
||||||
|
|
||||||
|
const run = await serviceLogger.startRun({
|
||||||
|
service: 'email_labeling',
|
||||||
|
message: `Labeling ${unlabeled.length} email${unlabeled.length === 1 ? '' : 's'}`,
|
||||||
|
trigger: 'timer',
|
||||||
|
});
|
||||||
|
|
||||||
|
const relativeFiles = unlabeled.map(f => path.relative(WorkDir, f));
|
||||||
|
const limitedFiles = limitEventItems(relativeFiles);
|
||||||
|
await serviceLogger.log({
|
||||||
|
type: 'changes_identified',
|
||||||
|
service: run.service,
|
||||||
|
runId: run.runId,
|
||||||
|
level: 'info',
|
||||||
|
message: `Found ${unlabeled.length} unlabeled email${unlabeled.length === 1 ? '' : 's'}`,
|
||||||
|
counts: { emails: unlabeled.length },
|
||||||
|
items: limitedFiles.items,
|
||||||
|
truncated: limitedFiles.truncated,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalBatches = Math.ceil(unlabeled.length / BATCH_SIZE);
|
||||||
|
let totalEdited = 0;
|
||||||
|
let hadError = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < unlabeled.length; i += BATCH_SIZE) {
|
||||||
|
const batchPaths = unlabeled.slice(i, i + BATCH_SIZE);
|
||||||
|
const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read file contents for the batch
|
||||||
|
const files: { path: string; content: string }[] = [];
|
||||||
|
for (const filePath of batchPaths) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
files.push({ path: filePath, content });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[EmailLabeling] Error reading ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[EmailLabeling] Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`);
|
||||||
|
await serviceLogger.log({
|
||||||
|
type: 'progress',
|
||||||
|
service: run.service,
|
||||||
|
runId: run.runId,
|
||||||
|
level: 'info',
|
||||||
|
message: `Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`,
|
||||||
|
step: 'batch',
|
||||||
|
current: batchNumber,
|
||||||
|
total: totalBatches,
|
||||||
|
details: { filesInBatch: files.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await labelEmailBatch(files);
|
||||||
|
totalEdited += result.filesEdited.size;
|
||||||
|
|
||||||
|
// Only mark files that were actually edited by the agent
|
||||||
|
for (const file of files) {
|
||||||
|
const relativePath = path.relative(WorkDir, file.path);
|
||||||
|
if (result.filesEdited.has(relativePath)) {
|
||||||
|
markFileAsLabeled(file.path, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLabelingState(state);
|
||||||
|
console.log(`[EmailLabeling] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files edited`);
|
||||||
|
} catch (error) {
|
||||||
|
hadError = true;
|
||||||
|
console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error);
|
||||||
|
await serviceLogger.log({
|
||||||
|
type: 'error',
|
||||||
|
service: run.service,
|
||||||
|
runId: run.runId,
|
||||||
|
level: 'error',
|
||||||
|
message: `Error processing batch ${batchNumber}`,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
context: { batchNumber },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lastRunTime = new Date().toISOString();
|
||||||
|
saveLabelingState(state);
|
||||||
|
|
||||||
|
await serviceLogger.log({
|
||||||
|
type: 'run_complete',
|
||||||
|
service: run.service,
|
||||||
|
runId: run.runId,
|
||||||
|
level: hadError ? 'error' : 'info',
|
||||||
|
message: `Email labeling complete: ${totalEdited} files labeled`,
|
||||||
|
durationMs: Date.now() - run.startedAt,
|
||||||
|
outcome: hadError ? 'error' : 'ok',
|
||||||
|
summary: {
|
||||||
|
totalEmails: unlabeled.length,
|
||||||
|
filesLabeled: totalEdited,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[EmailLabeling] Done. ${totalEdited} emails labeled.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point - runs as independent polling service
|
||||||
|
*/
|
||||||
|
export async function init() {
|
||||||
|
console.log('[EmailLabeling] Starting Email Labeling Service...');
|
||||||
|
console.log(`[EmailLabeling] Will check for unlabeled emails every ${SYNC_INTERVAL_MS / 1000} seconds`);
|
||||||
|
|
||||||
|
// Initial run
|
||||||
|
await processUnlabeledEmails();
|
||||||
|
|
||||||
|
// Periodic polling
|
||||||
|
while (true) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await processUnlabeledEmails();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EmailLabeling] Error in main loop:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
apps/x/packages/core/src/knowledge/labeling_agent.ts
Normal file
59
apps/x/packages/core/src/knowledge/labeling_agent.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { renderTagSystemForEmails } from './tag_system.js';
|
||||||
|
|
||||||
|
export function getRaw(): string {
|
||||||
|
return `---
|
||||||
|
model: gpt-5.2
|
||||||
|
tools:
|
||||||
|
workspace-readFile:
|
||||||
|
type: builtin
|
||||||
|
name: workspace-readFile
|
||||||
|
workspace-edit:
|
||||||
|
type: builtin
|
||||||
|
name: workspace-edit
|
||||||
|
workspace-readdir:
|
||||||
|
type: builtin
|
||||||
|
name: workspace-readdir
|
||||||
|
---
|
||||||
|
# Task
|
||||||
|
|
||||||
|
You are an email labeling agent. Given a batch of email files, you will classify each email and prepend YAML frontmatter with structured labels.
|
||||||
|
|
||||||
|
${renderTagSystemForEmails()}
|
||||||
|
|
||||||
|
# Instructions
|
||||||
|
|
||||||
|
1. For each email file provided in the message, read its content carefully.
|
||||||
|
2. Classify the email using the taxonomy above. Be accurate and conservative — only apply labels that clearly fit.
|
||||||
|
3. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Subject\` heading), and the newString should be the frontmatter followed by that same first line.
|
||||||
|
4. Always include \`processed: true\` and \`labeled_at\` with the current ISO timestamp.
|
||||||
|
5. If the email already has frontmatter (starts with \`---\`), skip it.
|
||||||
|
|
||||||
|
# Frontmatter Format
|
||||||
|
|
||||||
|
\`\`\`yaml
|
||||||
|
---
|
||||||
|
labels:
|
||||||
|
relationship:
|
||||||
|
- Investor
|
||||||
|
topics:
|
||||||
|
- Fundraising
|
||||||
|
- Finance
|
||||||
|
type: Intro
|
||||||
|
filter:
|
||||||
|
- Promotion
|
||||||
|
action: FYI
|
||||||
|
processed: true
|
||||||
|
labeled_at: "2026-02-28T12:00:00Z"
|
||||||
|
---
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
# Rules
|
||||||
|
|
||||||
|
- Every label category must be present in the frontmatter, even if empty (use \`[]\` for empty arrays).
|
||||||
|
- \`type\` and \`action\` are single values (strings), not arrays.
|
||||||
|
- \`relationship\`, \`topics\`, and \`filter\` are arrays.
|
||||||
|
- Use the exact label values from the taxonomy — do not invent new ones.
|
||||||
|
- The \`labeled_at\` timestamp should be the current time in ISO 8601 format.
|
||||||
|
- Process all files in the batch. Do not skip any unless they already have frontmatter.
|
||||||
|
`;
|
||||||
|
}
|
||||||
48
apps/x/packages/core/src/knowledge/labeling_state.ts
Normal file
48
apps/x/packages/core/src/knowledge/labeling_state.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { WorkDir } from '../config/config.js';
|
||||||
|
|
||||||
|
const STATE_FILE = path.join(WorkDir, 'labeling_state.json');
|
||||||
|
|
||||||
|
export interface LabelingState {
|
||||||
|
processedFiles: Record<string, { labeledAt: string }>;
|
||||||
|
lastRunTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadLabelingState(): LabelingState {
|
||||||
|
if (fs.existsSync(STATE_FILE)) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading labeling state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
processedFiles: {},
|
||||||
|
lastRunTime: new Date(0).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveLabelingState(state: LabelingState): void {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving labeling state:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markFileAsLabeled(filePath: string, state: LabelingState): void {
|
||||||
|
state.processedFiles[filePath] = {
|
||||||
|
labeledAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetLabelingState(): void {
|
||||||
|
const emptyState: LabelingState = {
|
||||||
|
processedFiles: {},
|
||||||
|
lastRunTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
saveLabelingState(emptyState);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
export const raw = `---
|
import { renderNoteTypesBlock } from './note_system.js';
|
||||||
|
import { renderNoteEffectRules } from './tag_system.js';
|
||||||
|
|
||||||
|
export function getRaw(): string {
|
||||||
|
return `---
|
||||||
model: gpt-5.2
|
model: gpt-5.2
|
||||||
tools:
|
tools:
|
||||||
workspace-writeFile:
|
workspace-writeFile:
|
||||||
|
|
@ -130,25 +134,15 @@ Either:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# The Core Rule: Medium Strictness
|
# The Core Rule: Label-Based Filtering
|
||||||
|
|
||||||
**MEDIUM STRICTNESS MODE**
|
**Emails now have YAML frontmatter with labels.** Use these labels to decide whether to process or skip.
|
||||||
|
|
||||||
**Meetings create notes because:**
|
**Meetings and voice memos always create notes** — no label check needed.
|
||||||
- You chose to spend time with these people
|
|
||||||
- If you met them, they matter enough to track
|
|
||||||
- Meeting transcripts have rich context
|
|
||||||
|
|
||||||
**Emails can create notes if:**
|
**For emails, read the YAML frontmatter labels and apply these rules:**
|
||||||
- The email contains personalized content (not mass mail)
|
|
||||||
- The sender seems relevant to your work (business context, not consumer services)
|
|
||||||
- The email is part of a meaningful exchange (not one-off transactional)
|
|
||||||
|
|
||||||
**Skip creating notes for:**
|
${renderNoteEffectRules()}
|
||||||
- Mass emails and newsletters
|
|
||||||
- Automated/transactional emails
|
|
||||||
- Consumer service providers (utilities, subscriptions, etc.)
|
|
||||||
- Cold sales outreach with no prior relationship indication
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -217,168 +211,40 @@ Emails containing calendar invites (\`.ics\` attachments or inline calendar data
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Step 1: Source Filtering
|
# Step 1: Source Filtering (Label-Based)
|
||||||
|
|
||||||
## Skip These Sources (Both Meetings and Emails)
|
## For Meetings and Voice Memos
|
||||||
|
Always process — no filtering needed.
|
||||||
|
|
||||||
### Mass Emails and Newsletters
|
## For Emails — Read YAML Frontmatter
|
||||||
|
|
||||||
**Indicators:**
|
Emails have YAML frontmatter with labels prepended by the labeling agent:
|
||||||
- Sent to a list (To: contains multiple addresses, or undisclosed-recipients)
|
|
||||||
- Unsubscribe link in body or footer
|
|
||||||
- From a no-reply or marketing address (noreply@, newsletter@, marketing@, hello@)
|
|
||||||
- Generic greeting ("Hi there", "Dear subscriber", "Hello!")
|
|
||||||
- Promotional language ("Don't miss out", "Limited time", "% off")
|
|
||||||
- Mailing list headers (List-Unsubscribe, Mailing-List)
|
|
||||||
- Sent via marketing platforms (via sendgrid, via mailchimp, etc.)
|
|
||||||
|
|
||||||
**Action:** SKIP with reason "Newsletter/mass email"
|
\`\`\`yaml
|
||||||
|
---
|
||||||
|
labels:
|
||||||
|
relationship:
|
||||||
|
- Investor
|
||||||
|
topics:
|
||||||
|
- Fundraising
|
||||||
|
type: Intro
|
||||||
|
filter: []
|
||||||
|
action: FYI
|
||||||
|
processed: true
|
||||||
|
labeled_at: "2026-02-28T12:00:00Z"
|
||||||
|
---
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
### Product Updates & Changelogs
|
## Decision Rules
|
||||||
|
|
||||||
**Indicators:**
|
${renderNoteEffectRules()}
|
||||||
- Subject contains: "changelog", "what's new", "product update", "release notes", "v1.x", "new features"
|
|
||||||
- Content describes feature releases, bug fixes, or product changes
|
|
||||||
- Sent to all users/customers (not personalized to you specifically)
|
|
||||||
- From tools/SaaS you use: Cal.com, Notion, Slack, Linear, Figma, etc.
|
|
||||||
- No action required from you — purely informational
|
|
||||||
- Written in announcement style, not conversational
|
|
||||||
|
|
||||||
**Examples to SKIP:**
|
|
||||||
- "Cal.com Changelog v6.1" — product update
|
|
||||||
- "What's new in Notion - January 2026" — feature announcement
|
|
||||||
- "Introducing new Slack features" — product marketing
|
|
||||||
- "Linear Release Notes" — changelog
|
|
||||||
|
|
||||||
**Action:** SKIP with reason "Product update/changelog"
|
|
||||||
|
|
||||||
### Cold Outreach / Sales Emails
|
|
||||||
|
|
||||||
**THE RULE: If someone emails you offering services and you never responded, SKIP.**
|
|
||||||
|
|
||||||
It doesn't matter how personalized, detailed, or relevant the pitch seems. If:
|
|
||||||
1. They initiated contact (you didn't reach out first)
|
|
||||||
2. They're offering services/products
|
|
||||||
3. You never replied or engaged
|
|
||||||
|
|
||||||
Then it's cold outreach and should be SKIPPED. Do NOT create notes for cold outreach senders or their organizations.
|
|
||||||
|
|
||||||
**EXCEPTION:** If they reference a prior real-world interaction, CREATE a note:
|
|
||||||
- "Great meeting you at [conference/event]"
|
|
||||||
- "Following up on our conversation at..."
|
|
||||||
- "It was nice chatting at [place]"
|
|
||||||
- "[Mutual contact] suggested I reach out after we met"
|
|
||||||
|
|
||||||
This indicates a real relationship that started offline, not cold outreach.
|
|
||||||
|
|
||||||
**Indicators:**
|
|
||||||
- Unsolicited contact from someone you've never interacted with
|
|
||||||
- Offering services you didn't request (HR, payroll, compliance, bookkeeping, recruiting, dev shops, marketing, etc.)
|
|
||||||
- Sales-y language: "wanted to reach out", "thought this might help", "quick question about your..."
|
|
||||||
- Mentions your company growth/funding/hiring/tech stack as a hook
|
|
||||||
- Attaches "free guides", "case studies", "resources", or "frameworks"
|
|
||||||
- Asks for a call/meeting without any prior relationship
|
|
||||||
- From domains you've never contacted or met with before
|
|
||||||
- No existing note for this person or organization
|
|
||||||
- **No reply from the user in the email thread**
|
|
||||||
|
|
||||||
**Examples to SKIP:**
|
|
||||||
- "Saw you raised funding, wanted to reach out about our services"
|
|
||||||
- "Quick question about your bookkeeping/compliance/hiring"
|
|
||||||
- "Shared this guide that might help with [your problem]"
|
|
||||||
- "Noticed you're scaling, we help startups with..."
|
|
||||||
- "Would love 15 minutes to show you how we can help"
|
|
||||||
- Detailed pitch about HR/payroll/India expansion services (still cold outreach!)
|
|
||||||
- Follow-up emails to previous cold outreach that got no response
|
|
||||||
|
|
||||||
**Key distinction:**
|
|
||||||
- **You reaching out to a vendor** → worth tracking (you initiated)
|
|
||||||
- **You replied to their outreach** → worth tracking (you engaged)
|
|
||||||
- **Vendor cold emailing you with no response** → SKIP (no relationship exists)
|
|
||||||
|
|
||||||
**IMPORTANT: CC'd people on cold outreach**
|
|
||||||
When an email is identified as cold outreach, skip notes for ALL parties involved:
|
|
||||||
- The sender (the person doing the outreach)
|
|
||||||
- Anyone CC'd on the email (colleagues of the sender, other contacts they're trying to connect)
|
|
||||||
- The organization they represent
|
|
||||||
|
|
||||||
If someone only appears in your memory as "CC'd on outreach emails from [Sender]", they don't warrant a note — they're just incidentally included in cold outreach, not a real relationship.
|
|
||||||
|
|
||||||
**Action:** SKIP with reason "Cold outreach/sales email - no engagement from user"
|
|
||||||
|
|
||||||
### Automated/Transactional
|
|
||||||
|
|
||||||
**Indicators:**
|
|
||||||
- From automated systems (notifications@, alerts@, no-reply@)
|
|
||||||
- Password resets, login alerts, shipping notifications
|
|
||||||
- Calendar invites without substance
|
|
||||||
- Receipts and invoices (unless from key vendor/customer)
|
|
||||||
- GitHub/Jira/Slack notifications
|
|
||||||
|
|
||||||
**Action:** SKIP with reason "Automated/transactional"
|
|
||||||
|
|
||||||
### Low-Signal
|
|
||||||
|
|
||||||
**Indicators:**
|
|
||||||
- Very short with no substance ("Thanks!", "Sounds good", "Got it")
|
|
||||||
- Only contains forwarded message with no commentary
|
|
||||||
- Auto-replies ("I'm out of office")
|
|
||||||
|
|
||||||
**Action:** SKIP with reason "Low signal"
|
|
||||||
|
|
||||||
### Consumer Services (Medium strictness specific)
|
|
||||||
|
|
||||||
**Indicators:**
|
|
||||||
- From consumer service companies (utilities, streaming, retail)
|
|
||||||
- Account management emails
|
|
||||||
- Subscription confirmations
|
|
||||||
- Delivery notifications
|
|
||||||
|
|
||||||
**Action:** SKIP with reason "Consumer service"
|
|
||||||
|
|
||||||
### Infrastructure & SaaS Providers
|
|
||||||
|
|
||||||
**Skip emails from these types of services:**
|
|
||||||
- Domain registrars: GoDaddy, Namecheap, Google Domains, Cloudflare
|
|
||||||
- Hosting providers: AWS, Google Cloud, Azure, DigitalOcean, Heroku, Vercel, Netlify
|
|
||||||
- Email providers: Google Workspace, Microsoft 365, Zoho
|
|
||||||
- Payment processors: Stripe, PayPal, Square, Razorpay
|
|
||||||
- Developer tools: GitHub, GitLab, Bitbucket, npm, Docker Hub
|
|
||||||
- Analytics: Google Analytics, Mixpanel, Amplitude, Segment
|
|
||||||
- Auth providers: Auth0, Okta, Firebase Auth
|
|
||||||
- Support platforms: Zendesk, Intercom, Freshdesk
|
|
||||||
- HR/Payroll: Gusto, Rippling, Deel, Remote
|
|
||||||
|
|
||||||
**Indicators:**
|
|
||||||
- Automated system notifications (renewal reminders, usage alerts, security notices)
|
|
||||||
- No personalized content from a human
|
|
||||||
- From domains like @godaddy.com, @aws.amazon.com, @stripe.com, etc.
|
|
||||||
- Templates about account status, billing, or technical alerts
|
|
||||||
|
|
||||||
**Action:** SKIP with reason "Infrastructure/SaaS provider notification"
|
|
||||||
|
|
||||||
## Email-Specific Processing (Medium Strictness)
|
|
||||||
|
|
||||||
For emails, evaluate if the content is personalized and business-relevant:
|
|
||||||
|
|
||||||
**Create note if:**
|
|
||||||
- The email is personally addressed and substantive
|
|
||||||
- The sender appears to be from a business/organization relevant to your work
|
|
||||||
- The content discusses work, projects, opportunities, or professional topics
|
|
||||||
- It's a warm intro from anyone (not just existing contacts)
|
|
||||||
- It's a thoughtful cold outreach that's specific to your work
|
|
||||||
|
|
||||||
**Do not create note if:**
|
|
||||||
- Clearly mass/templated email
|
|
||||||
- Consumer service interaction
|
|
||||||
- Generic sales pitch with no personalization
|
|
||||||
|
|
||||||
## Filter Decision Output
|
## Filter Decision Output
|
||||||
|
|
||||||
If skipping:
|
If skipping:
|
||||||
\`\`\`
|
\`\`\`
|
||||||
SKIP
|
SKIP
|
||||||
Reason: {reason}
|
Reason: Labels indicate skip-only categories: {list the labels}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
If processing, continue to Step 2.
|
If processing, continue to Step 2.
|
||||||
|
|
@ -552,16 +418,16 @@ Resolution Map:
|
||||||
- "the integration" → "Acme Integration" (same project)
|
- "the integration" → "Acme Integration" (same project)
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
## 4b: Apply Source Type Rules (Medium Strictness)
|
## 4b: Apply Source Type Rules
|
||||||
|
|
||||||
**If source_type == "meeting":**
|
**If source_type == "meeting" or "voice_memo":**
|
||||||
- Resolved entities → Update existing notes
|
- Resolved entities → Update existing notes
|
||||||
- New entities that pass filters → Create new notes
|
- New entities that pass filters → Create new notes
|
||||||
|
|
||||||
**If source_type == "email" (MEDIUM STRICTNESS):**
|
**If source_type == "email":**
|
||||||
|
- The email already passed label-based filtering in Step 1
|
||||||
- Resolved entities → Update existing notes
|
- Resolved entities → Update existing notes
|
||||||
- New entities → Create notes IF the email is personalized and business-relevant
|
- New entities → Create notes (the labels already confirmed this email is worth processing)
|
||||||
- New entities from cold sales pitches without personalization → Skip
|
|
||||||
|
|
||||||
## 4c: Disambiguation Rules
|
## 4c: Disambiguation Rules
|
||||||
|
|
||||||
|
|
@ -628,39 +494,23 @@ For entities not resolved to existing notes, determine if they warrant new notes
|
||||||
|
|
||||||
## People
|
## People
|
||||||
|
|
||||||
### Who Gets a Note (Medium Strictness)
|
### Who Gets a Note
|
||||||
|
|
||||||
**CREATE a note for people who are:**
|
**CREATE a note for people who are:**
|
||||||
- External (not @user.domain)
|
- External (not @user.domain)
|
||||||
- Attendees in meetings
|
- Attendees in meetings
|
||||||
- Email correspondents sending personalized, business-relevant content
|
- Email correspondents (emails that reach this step already passed label-based filtering)
|
||||||
- Decision makers or contacts at customers, prospects, or partners
|
- Decision makers or contacts at customers, prospects, or partners
|
||||||
- Investors or potential investors
|
- Investors or potential investors
|
||||||
- Candidates you are interviewing
|
- Candidates you are interviewing
|
||||||
- Advisors or mentors
|
- Advisors or mentors
|
||||||
- Key collaborators
|
- Key collaborators
|
||||||
- Introducers who connect you to valuable contacts
|
- Introducers who connect you to valuable contacts
|
||||||
- Anyone reaching out with a specific, relevant opportunity
|
|
||||||
|
|
||||||
**DO NOT create notes for:**
|
**DO NOT create notes for:**
|
||||||
- Transactional service providers (bank employees, support reps)
|
|
||||||
- One-time administrative contacts
|
|
||||||
- Large group meeting attendees you didn't interact with
|
- Large group meeting attendees you didn't interact with
|
||||||
- Internal colleagues (@user.domain)
|
- Internal colleagues (@user.domain)
|
||||||
- Assistants handling only logistics
|
- Assistants handling only logistics
|
||||||
- Generic role-based contacts
|
|
||||||
- Consumer service representatives
|
|
||||||
- Generic cold sales outreach with no personalization
|
|
||||||
|
|
||||||
### The Relevance Test (Medium Strictness)
|
|
||||||
|
|
||||||
Ask: Is this person relevant to my professional work or goals?
|
|
||||||
|
|
||||||
- Sarah Chen, VP Engineering evaluating your product → **Yes, create note**
|
|
||||||
- James from HSBC who set up your account → **No, skip**
|
|
||||||
- Investor reaching out about your company → **Yes, create note**
|
|
||||||
- Cold recruiter with a generic pitch → **No, skip**
|
|
||||||
- Someone reaching out about a specific opportunity → **Yes, create note**
|
|
||||||
|
|
||||||
### Role Inference
|
### Role Inference
|
||||||
|
|
||||||
|
|
@ -1025,153 +875,18 @@ After writing, verify links go both ways.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Note Templates
|
${renderNoteTypesBlock()}
|
||||||
|
|
||||||
## People
|
|
||||||
\`\`\`markdown
|
|
||||||
# {Full Name}
|
|
||||||
|
|
||||||
## Info
|
|
||||||
**Role:** {role, or inferred role with qualifier, or leave blank if truly unknown}
|
|
||||||
**Organization:** [[Organizations/{organization}]] or leave blank
|
|
||||||
**Email:** {email or leave blank}
|
|
||||||
**Aliases:** {comma-separated: first name, nicknames, email}
|
|
||||||
**First met:** {YYYY-MM-DD}
|
|
||||||
**Last seen:** {YYYY-MM-DD}
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
{2-3 sentences: Who they are, why you know them, what you're working on together.}
|
|
||||||
|
|
||||||
## Connected to
|
|
||||||
- [[Organizations/{Organization}]] — works at
|
|
||||||
- [[People/{Person}]] — {colleague, introduced by, reports to}
|
|
||||||
- [[Projects/{Project}]] — {role}
|
|
||||||
|
|
||||||
## Activity
|
|
||||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
|
||||||
|
|
||||||
## Key facts
|
|
||||||
{Substantive facts only. Leave empty if none.}
|
|
||||||
|
|
||||||
## Open items
|
|
||||||
{Commitments and next steps only. Leave empty if none.}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Organizations
|
|
||||||
\`\`\`markdown
|
|
||||||
# {Organization Name}
|
|
||||||
|
|
||||||
## Info
|
|
||||||
**Type:** {company|team|institution|other}
|
|
||||||
**Industry:** {industry or leave blank}
|
|
||||||
**Relationship:** {customer|prospect|partner|competitor|vendor|other}
|
|
||||||
**Domain:** {primary email domain}
|
|
||||||
**Aliases:** {comma-separated: short names, abbreviations}
|
|
||||||
**First met:** {YYYY-MM-DD}
|
|
||||||
**Last seen:** {YYYY-MM-DD}
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
{2-3 sentences: What this org is, what your relationship is.}
|
|
||||||
|
|
||||||
## People
|
|
||||||
- [[People/{Person}]] — {role}
|
|
||||||
|
|
||||||
## Contacts
|
|
||||||
{For transactional contacts who don't get their own notes}
|
|
||||||
|
|
||||||
## Projects
|
|
||||||
- [[Projects/{Project}]] — {relationship}
|
|
||||||
|
|
||||||
## Activity
|
|
||||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
|
||||||
|
|
||||||
## Key facts
|
|
||||||
{Substantive facts only. Leave empty if none.}
|
|
||||||
|
|
||||||
## Open items
|
|
||||||
{Commitments and next steps only. Leave empty if none.}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Projects
|
|
||||||
\`\`\`markdown
|
|
||||||
# {Project Name}
|
|
||||||
|
|
||||||
## Info
|
|
||||||
**Type:** {deal|product|initiative|hiring|other}
|
|
||||||
**Status:** {active|planning|on hold|completed|cancelled}
|
|
||||||
**Started:** {YYYY-MM-DD or leave blank}
|
|
||||||
**Last activity:** {YYYY-MM-DD}
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
{2-3 sentences: What this project is, goal, current state.}
|
|
||||||
|
|
||||||
## People
|
|
||||||
- [[People/{Person}]] — {role}
|
|
||||||
|
|
||||||
## Organizations
|
|
||||||
- [[Organizations/{Org}]] — {customer|partner|etc.}
|
|
||||||
|
|
||||||
## Related
|
|
||||||
- [[Topics/{Topic}]] — {relationship}
|
|
||||||
- [[Projects/{Project}]] — {relationship}
|
|
||||||
|
|
||||||
## Timeline
|
|
||||||
**{YYYY-MM-DD}** ({meeting|email})
|
|
||||||
{What happened.}
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
- **{YYYY-MM-DD}**: {Decision}. {Rationale}.
|
|
||||||
|
|
||||||
## Open items
|
|
||||||
{Commitments and next steps only. Leave empty if none.}
|
|
||||||
|
|
||||||
## Key facts
|
|
||||||
{Substantive facts only. Leave empty if none.}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Topics
|
|
||||||
\`\`\`markdown
|
|
||||||
# {Topic Name}
|
|
||||||
|
|
||||||
## About
|
|
||||||
{1-2 sentences: What this topic covers.}
|
|
||||||
|
|
||||||
**Keywords:** {comma-separated}
|
|
||||||
**Aliases:** {other ways this topic is referenced}
|
|
||||||
**First mentioned:** {YYYY-MM-DD}
|
|
||||||
**Last mentioned:** {YYYY-MM-DD}
|
|
||||||
|
|
||||||
## Related
|
|
||||||
- [[People/{Person}]] — {relationship}
|
|
||||||
- [[Organizations/{Org}]] — {relationship}
|
|
||||||
- [[Projects/{Project}]] — {relationship}
|
|
||||||
|
|
||||||
## Log
|
|
||||||
**{YYYY-MM-DD}** ({meeting|email}: {title})
|
|
||||||
{Summary with [[Folder/Name]] links}
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
- **{YYYY-MM-DD}**: {Decision}
|
|
||||||
|
|
||||||
## Open items
|
|
||||||
{Commitments and next steps only. Leave empty if none.}
|
|
||||||
|
|
||||||
## Key facts
|
|
||||||
{Substantive facts only. Leave empty if none.}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Summary: Medium Strictness Rules
|
# Summary: Label-Based Rules
|
||||||
|
|
||||||
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|
||||||
|-------------|---------------|----------------|------------------------|
|
|-------------|---------------|----------------|------------------------|
|
||||||
| Meeting | Yes | Yes | Yes |
|
| Meeting | Yes | Yes | Yes |
|
||||||
| Voice memo | Yes | Yes | Yes |
|
| Voice memo | Yes | Yes | Yes |
|
||||||
| Email (personalized, business-relevant) | Yes | Yes | Yes |
|
| Email (has create label) | Yes | Yes | Yes |
|
||||||
| Email (mass/automated/consumer) | No (SKIP) | No | No |
|
| Email (only skip labels) | No (SKIP) | No | No |
|
||||||
| Email (cold outreach with personalization) | Yes | Yes | Yes |
|
|
||||||
| Email (generic cold outreach) | No | No | No |
|
|
||||||
|
|
||||||
**Voice memo activity format:** Always include a link to the source voice memo:
|
**Voice memo activity format:** Always include a link to the source voice memo:
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
@ -1198,7 +913,7 @@ Before completing, verify:
|
||||||
|
|
||||||
**Source Type:**
|
**Source Type:**
|
||||||
- [ ] Correctly identified as meeting or email
|
- [ ] Correctly identified as meeting or email
|
||||||
- [ ] Applied correct medium strictness rules
|
- [ ] Applied label-based filtering rules correctly
|
||||||
|
|
||||||
**Resolution:**
|
**Resolution:**
|
||||||
- [ ] Extracted all name variants from source
|
- [ ] Extracted all name variants from source
|
||||||
|
|
@ -1233,4 +948,5 @@ Before completing, verify:
|
||||||
- [ ] Dates are YYYY-MM-DD
|
- [ ] Dates are YYYY-MM-DD
|
||||||
- [ ] Bidirectional links are consistent
|
- [ ] Bidirectional links are consistent
|
||||||
- [ ] New notes in correct folders
|
- [ ] New notes in correct folders
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,874 +0,0 @@
|
||||||
export const raw = `---
|
|
||||||
model: gpt-5.2
|
|
||||||
tools:
|
|
||||||
workspace-writeFile:
|
|
||||||
type: builtin
|
|
||||||
name: workspace-writeFile
|
|
||||||
workspace-readFile:
|
|
||||||
type: builtin
|
|
||||||
name: workspace-readFile
|
|
||||||
workspace-edit:
|
|
||||||
type: builtin
|
|
||||||
name: workspace-edit
|
|
||||||
workspace-readdir:
|
|
||||||
type: builtin
|
|
||||||
name: workspace-readdir
|
|
||||||
workspace-mkdir:
|
|
||||||
type: builtin
|
|
||||||
name: workspace-mkdir
|
|
||||||
workspace-grep:
|
|
||||||
type: builtin
|
|
||||||
name: workspace-grep
|
|
||||||
workspace-glob:
|
|
||||||
type: builtin
|
|
||||||
name: workspace-glob
|
|
||||||
---
|
|
||||||
# Task
|
|
||||||
|
|
||||||
You are a memory agent. Given a single source file (email, meeting transcript, or voice memo), you will:
|
|
||||||
|
|
||||||
1. **Determine source type (meeting or email)**
|
|
||||||
2. **Evaluate if the source is worth processing**
|
|
||||||
3. **Search for all existing related notes**
|
|
||||||
4. **Resolve entities to canonical names**
|
|
||||||
5. Identify new entities worth tracking
|
|
||||||
6. Extract structured information (decisions, commitments, key facts)
|
|
||||||
7. **Detect state changes (status updates, resolved items, role changes)**
|
|
||||||
8. Create new notes or update existing notes
|
|
||||||
9. **Apply state changes to existing notes**
|
|
||||||
|
|
||||||
The core rule: **Capture broadly. Meetings, voice memos, and emails create notes for most external contacts.**
|
|
||||||
|
|
||||||
You have full read access to the existing knowledge directory. Use this extensively to:
|
|
||||||
- Find existing notes for people, organizations, projects mentioned
|
|
||||||
- Resolve ambiguous names (find existing note for "David")
|
|
||||||
- Understand existing relationships before updating
|
|
||||||
- Avoid creating duplicate notes
|
|
||||||
- Maintain consistency with existing content
|
|
||||||
- **Detect when new information changes the state of existing notes**
|
|
||||||
|
|
||||||
# Inputs
|
|
||||||
|
|
||||||
1. **source_file**: Path to a single file to process (email or meeting transcript)
|
|
||||||
2. **knowledge_folder**: Path to Obsidian vault (read/write access)
|
|
||||||
3. **user**: Information about the owner of this memory
|
|
||||||
- name: e.g., "Arj"
|
|
||||||
- email: e.g., "arj@rowboat.com"
|
|
||||||
- domain: e.g., "rowboat.com"
|
|
||||||
4. **knowledge_index**: A pre-built index of all existing notes (provided in the message)
|
|
||||||
|
|
||||||
# Knowledge Base Index
|
|
||||||
|
|
||||||
**IMPORTANT:** You will receive a pre-built index of all existing notes at the start of each request. This index contains:
|
|
||||||
- All people notes with their names, emails, aliases, and organizations
|
|
||||||
- All organization notes with their names, domains, and aliases
|
|
||||||
- All project notes with their names and statuses
|
|
||||||
- All topic notes with their names and keywords
|
|
||||||
|
|
||||||
**USE THE INDEX for entity resolution instead of grep/search commands.** This is much faster.
|
|
||||||
|
|
||||||
When you need to:
|
|
||||||
- Check if a person exists → Look up by name/email/alias in the index
|
|
||||||
- Find an organization → Look up by name/domain in the index
|
|
||||||
- Resolve "David" to a full name → Check index for people with that name/alias + organization context
|
|
||||||
|
|
||||||
**Only use \`cat\` to read full note content** when you need details not in the index (e.g., existing activity logs, open items).
|
|
||||||
|
|
||||||
# Tools Available
|
|
||||||
|
|
||||||
You have access to these tools:
|
|
||||||
|
|
||||||
**For reading files:**
|
|
||||||
\`\`\`
|
|
||||||
workspace-readFile({ path: "knowledge/People/Sarah Chen.md" })
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**For creating NEW files:**
|
|
||||||
\`\`\`
|
|
||||||
workspace-writeFile({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." })
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**For editing EXISTING files (preferred for updates):**
|
|
||||||
\`\`\`
|
|
||||||
workspace-edit({
|
|
||||||
path: "knowledge/People/Sarah Chen.md",
|
|
||||||
oldString: "## Activity\\n",
|
|
||||||
newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n"
|
|
||||||
})
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**For listing directories:**
|
|
||||||
\`\`\`
|
|
||||||
workspace-readdir({ path: "knowledge/People" })
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**For creating directories:**
|
|
||||||
\`\`\`
|
|
||||||
workspace-mkdir({ path: "knowledge/Projects", recursive: true })
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**For searching files:**
|
|
||||||
\`\`\`
|
|
||||||
workspace-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" })
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**For finding files by pattern:**
|
|
||||||
\`\`\`
|
|
||||||
workspace-glob({ pattern: "**/*.md", cwd: "knowledge/People" })
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**IMPORTANT:**
|
|
||||||
- Use \`workspace-edit\` for updating existing notes (adding activity, updating fields)
|
|
||||||
- Use \`workspace-writeFile\` only for creating new notes
|
|
||||||
- Prefer the knowledge_index for entity resolution (it's faster than grep)
|
|
||||||
|
|
||||||
# Output
|
|
||||||
|
|
||||||
Either:
|
|
||||||
- **SKIP** with reason, if source should be ignored
|
|
||||||
- Updated or new markdown files in notes_folder
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# The Core Rule: Low Strictness - Capture Broadly
|
|
||||||
|
|
||||||
**LOW STRICTNESS MODE**
|
|
||||||
|
|
||||||
This mode prioritizes comprehensive capture over selectivity. The goal is to never miss a potentially important contact.
|
|
||||||
|
|
||||||
**Meetings create notes for:**
|
|
||||||
- All external attendees (anyone not @user.domain)
|
|
||||||
|
|
||||||
**Emails create notes for:**
|
|
||||||
- Any personalized email from an identifiable sender
|
|
||||||
- Anyone who reaches out directly
|
|
||||||
- Any external contact who communicates with you
|
|
||||||
|
|
||||||
**Only skip:**
|
|
||||||
- Obvious automated/system emails (no human sender)
|
|
||||||
- Mass newsletters with unsubscribe links
|
|
||||||
- Truly anonymous or unidentifiable senders
|
|
||||||
|
|
||||||
**Philosophy:** It's better to have a note you don't need than to miss tracking someone important.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 0: Determine Source Type
|
|
||||||
|
|
||||||
Read the source file and determine if it's a meeting or email.
|
|
||||||
\`\`\`
|
|
||||||
workspace-readFile({ path: "{source_file}" })
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**Meeting indicators:**
|
|
||||||
- Has \`Attendees:\` field
|
|
||||||
- Has \`Meeting:\` title
|
|
||||||
- Transcript format with speaker labels
|
|
||||||
|
|
||||||
**Email indicators:**
|
|
||||||
- Has \`From:\` and \`To:\` fields
|
|
||||||
- Has \`Subject:\` field
|
|
||||||
- Email signature
|
|
||||||
|
|
||||||
**Voice memo indicators:**
|
|
||||||
- Has \`**Type:** voice memo\` field
|
|
||||||
- Has \`**Path:**\` field with path like \`Voice Memos/YYYY-MM-DD/...\`
|
|
||||||
- Has \`## Transcript\` section
|
|
||||||
|
|
||||||
**Set processing mode:**
|
|
||||||
- \`source_type = "meeting"\` → Create notes for all external attendees
|
|
||||||
- \`source_type = "email"\` → Create notes for sender if identifiable human
|
|
||||||
- \`source_type = "voice_memo"\` → Create notes for all mentioned entities (treat like meetings)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Calendar Invite Emails
|
|
||||||
|
|
||||||
Emails containing calendar invites (\`.ics\` attachments) are **high signal** - a scheduled meeting means this person matters.
|
|
||||||
|
|
||||||
**How to identify:**
|
|
||||||
- Subject contains "Invitation:", "Accepted:", "Declined:", or "Updated:"
|
|
||||||
- Has \`.ics\` attachment reference
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
1. **CREATE a note for the primary contact** - the person you're meeting with
|
|
||||||
2. **Skip automated notifications** - from calendar-no-reply@google.com with no human sender
|
|
||||||
3. **Skip "Accepted/Declined" responses** - just RSVP confirmations
|
|
||||||
|
|
||||||
Once a note exists, subsequent emails will enrich it. When the meeting happens, the transcript adds more detail.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 1: Source Filtering (Minimal)
|
|
||||||
|
|
||||||
## Skip Only These Sources
|
|
||||||
|
|
||||||
### Mass Newsletters
|
|
||||||
|
|
||||||
**Indicators (must have MULTIPLE of these):**
|
|
||||||
- Unsubscribe link in body or footer
|
|
||||||
- From a marketing address (noreply@, newsletter@, marketing@)
|
|
||||||
- Sent to multiple recipients or undisclosed-recipients
|
|
||||||
- Sent via marketing platforms (via sendgrid, via mailchimp, etc.)
|
|
||||||
|
|
||||||
**Action:** SKIP with reason "Mass newsletter"
|
|
||||||
|
|
||||||
### Purely Automated (No Human Sender)
|
|
||||||
|
|
||||||
**Indicators:**
|
|
||||||
- From automated systems with no human behind them (alerts@, notifications@)
|
|
||||||
- Password resets, login alerts
|
|
||||||
- System notifications (GitHub automated, CI/CD alerts)
|
|
||||||
- Receipt confirmations with no human contact info
|
|
||||||
|
|
||||||
**Action:** SKIP with reason "Automated system message"
|
|
||||||
|
|
||||||
### Truly Low-Signal
|
|
||||||
|
|
||||||
**Indicators (must be clearly content-free):**
|
|
||||||
- Body is ONLY "Thanks!", "Got it", "OK" with nothing else
|
|
||||||
- Auto-replies ("I'm out of office") with no human context
|
|
||||||
|
|
||||||
**Action:** SKIP with reason "No substantive content"
|
|
||||||
|
|
||||||
## Process Everything Else
|
|
||||||
|
|
||||||
**Important:** When in doubt, PROCESS. In low strictness mode, we err on the side of capturing more.
|
|
||||||
|
|
||||||
If skipping:
|
|
||||||
\`\`\`
|
|
||||||
SKIP
|
|
||||||
Reason: {reason}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
If processing, continue to Step 2.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 2: Read and Parse Source File
|
|
||||||
\`\`\`
|
|
||||||
workspace-readFile({ path: "{source_file}" })
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Extract metadata:
|
|
||||||
|
|
||||||
**For meetings:**
|
|
||||||
- **Date:** From header or filename
|
|
||||||
- **Title:** Meeting name
|
|
||||||
- **Attendees:** List of participants
|
|
||||||
- **Duration:** If available
|
|
||||||
|
|
||||||
**For emails:**
|
|
||||||
- **Date:** From \`Date:\` header
|
|
||||||
- **Subject:** From \`Subject:\` header
|
|
||||||
- **From:** Sender email/name
|
|
||||||
- **To/Cc:** Recipients
|
|
||||||
|
|
||||||
## 2a: Exclude Self
|
|
||||||
|
|
||||||
Never create or update notes for:
|
|
||||||
- The user (matches user.name, user.email, or @user.domain)
|
|
||||||
- Anyone @{user.domain} (colleagues at user's company)
|
|
||||||
|
|
||||||
Filter these out from attendees/participants before proceeding.
|
|
||||||
|
|
||||||
## 2b: Extract All Name Variants
|
|
||||||
|
|
||||||
From the source, collect every way entities are referenced:
|
|
||||||
|
|
||||||
**People variants:**
|
|
||||||
- Full names: "Sarah Chen"
|
|
||||||
- First names only: "Sarah"
|
|
||||||
- Last names only: "Chen"
|
|
||||||
- Initials: "S. Chen"
|
|
||||||
- Email addresses: "sarah@acme.com"
|
|
||||||
- Roles/titles: "their CTO", "the VP of Engineering"
|
|
||||||
|
|
||||||
**Organization variants:**
|
|
||||||
- Full names: "Acme Corporation"
|
|
||||||
- Short names: "Acme"
|
|
||||||
- Abbreviations: "AC"
|
|
||||||
- Email domains: "@acme.com"
|
|
||||||
|
|
||||||
**Project variants:**
|
|
||||||
- Explicit names: "Project Atlas"
|
|
||||||
- Descriptive references: "the integration", "the pilot", "the deal"
|
|
||||||
|
|
||||||
Create a list of all variants found.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 3: Look Up Existing Notes in Index
|
|
||||||
|
|
||||||
**Use the provided knowledge_index to find existing notes. Do NOT use grep commands.**
|
|
||||||
|
|
||||||
## 3a: Look Up People
|
|
||||||
|
|
||||||
For each person variant (name, email, alias), check the index:
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
From index, find matches for:
|
|
||||||
- "Sarah Chen" → Check People table for matching name
|
|
||||||
- "Sarah" → Check People table for matching name or alias
|
|
||||||
- "sarah@acme.com" → Check People table for matching email
|
|
||||||
- "@acme.com" → Check People table for matching organization or check Organizations for domain
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## 3b: Look Up Organizations
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
From index, find matches for:
|
|
||||||
- "Acme Corp" → Check Organizations table for matching name
|
|
||||||
- "Acme" → Check Organizations table for matching name or alias
|
|
||||||
- "acme.com" → Check Organizations table for matching domain
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## 3c: Look Up Projects and Topics
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
From index, find matches for:
|
|
||||||
- "the pilot" → Check Projects table for related names
|
|
||||||
- "SOC 2" → Check Topics table for matching keywords
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## 3d: Read Full Notes When Needed
|
|
||||||
|
|
||||||
Only read the full note content when you need details not in the index (e.g., activity logs, open items):
|
|
||||||
\`\`\`
|
|
||||||
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**Why read these notes:**
|
|
||||||
- Find canonical names (David → David Kim)
|
|
||||||
- Check Aliases fields for known variants
|
|
||||||
- Understand existing relationships
|
|
||||||
- See organization context for disambiguation
|
|
||||||
- Check what's already captured (avoid duplicates)
|
|
||||||
- Review open items (some might be resolved)
|
|
||||||
- **Check current status fields (might need updating)**
|
|
||||||
- **Check current roles (might have changed)**
|
|
||||||
|
|
||||||
## 3e: Matching Criteria
|
|
||||||
|
|
||||||
Use these criteria to determine if a variant matches an existing note:
|
|
||||||
|
|
||||||
**People matching:**
|
|
||||||
|
|
||||||
| Source has | Note has | Match if |
|
|
||||||
|------------|----------|----------|
|
|
||||||
| First name "Sarah" | Full name "Sarah Chen" | Same organization context |
|
|
||||||
| Email "sarah@acme.com" | Email field | Exact match |
|
|
||||||
| Email domain "@acme.com" | Organization "Acme Corp" | Domain matches org |
|
|
||||||
| Role "VP Engineering" | Role field | Same org + same role |
|
|
||||||
| First name + company context | Full name + Organization | Company matches |
|
|
||||||
| Any variant | Aliases field | Listed in aliases |
|
|
||||||
|
|
||||||
**Organization matching:**
|
|
||||||
|
|
||||||
| Source has | Note has | Match if |
|
|
||||||
|------------|----------|----------|
|
|
||||||
| "Acme" | "Acme Corp" | Substring match |
|
|
||||||
| "Acme Corporation" | "Acme Corp" | Same root name |
|
|
||||||
| "@acme.com" | Domain field | Domain matches |
|
|
||||||
| Any variant | Aliases field | Listed in aliases |
|
|
||||||
|
|
||||||
**Project matching:**
|
|
||||||
|
|
||||||
| Source has | Note has | Match if |
|
|
||||||
|------------|----------|----------|
|
|
||||||
| "the pilot" | "Acme Pilot" | Same org context in source |
|
|
||||||
| "integration project" | "Acme Integration" | Same org + similar type |
|
|
||||||
| "Series A" | "Series A Fundraise" | Unique identifier match |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 4: Resolve Entities to Canonical Names
|
|
||||||
|
|
||||||
Using the search results from Step 3, resolve each variant to a canonical name.
|
|
||||||
|
|
||||||
## 4a: Build Resolution Map
|
|
||||||
|
|
||||||
Create a mapping from every source reference to its canonical form.
|
|
||||||
|
|
||||||
## 4b: Apply Source Type Rules (Low Strictness)
|
|
||||||
|
|
||||||
**If source_type == "meeting":**
|
|
||||||
- Resolved entities → Update existing notes
|
|
||||||
- New entities → Create new notes for ALL external attendees
|
|
||||||
|
|
||||||
**If source_type == "email" (LOW STRICTNESS):**
|
|
||||||
- Resolved entities → Update existing notes
|
|
||||||
- New entities → Create notes for the sender and any mentioned contacts
|
|
||||||
|
|
||||||
## 4c: Disambiguation Rules
|
|
||||||
|
|
||||||
When multiple candidates match a variant, disambiguate by:
|
|
||||||
1. Email match (definitive)
|
|
||||||
2. Organization context (strong signal)
|
|
||||||
3. Role match
|
|
||||||
4. Recency (tiebreaker)
|
|
||||||
|
|
||||||
## 4d: Resolution Map Output
|
|
||||||
|
|
||||||
Final resolution map before proceeding:
|
|
||||||
\`\`\`
|
|
||||||
RESOLVED (use canonical name with absolute path):
|
|
||||||
- "Sarah", "Sarah Chen", "sarah@acme.com" → [[People/Sarah Chen]]
|
|
||||||
|
|
||||||
NEW ENTITIES (create notes):
|
|
||||||
- "Jennifer" (CTO, Acme Corp) → Create [[People/Jennifer]]
|
|
||||||
|
|
||||||
AMBIGUOUS (create with disambiguation note):
|
|
||||||
- "Mike" (no context) → Create [[People/Mike]] with note about ambiguity
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 5: Identify New Entities (Low Strictness - Capture Broadly)
|
|
||||||
|
|
||||||
For entities not resolved to existing notes, create notes for most of them.
|
|
||||||
|
|
||||||
## People
|
|
||||||
|
|
||||||
### Who Gets a Note (Low Strictness)
|
|
||||||
|
|
||||||
**CREATE a note for:**
|
|
||||||
- ALL external meeting attendees (not @user.domain)
|
|
||||||
- ALL email senders with identifiable names/emails
|
|
||||||
- Anyone CC'd on emails who seems relevant
|
|
||||||
- Anyone mentioned by name in conversations
|
|
||||||
- Cold outreach senders (even if unsolicited)
|
|
||||||
- Sales reps, recruiters, service providers
|
|
||||||
- Anyone who might be useful to remember later
|
|
||||||
|
|
||||||
**DO NOT create notes for:**
|
|
||||||
- Internal colleagues (@user.domain)
|
|
||||||
- Truly anonymous/unidentifiable senders
|
|
||||||
- System-generated sender names with no human behind them
|
|
||||||
|
|
||||||
### The Low Strictness Test
|
|
||||||
|
|
||||||
Ask: Could this person ever be useful to remember?
|
|
||||||
|
|
||||||
- Sarah Chen, VP Engineering → **Yes, create note**
|
|
||||||
- James from HSBC → **Yes, create note** (might need banking help again)
|
|
||||||
- Random recruiter → **Yes, create note** (might want to contact later)
|
|
||||||
- Cold sales person → **Yes, create note** (might be relevant someday)
|
|
||||||
- Support rep → **Yes, create note** (might need them again)
|
|
||||||
|
|
||||||
### Role Inference
|
|
||||||
|
|
||||||
If role is not explicitly stated, infer from context. Write "Unknown" only if truly impossible to infer anything.
|
|
||||||
|
|
||||||
### Relationship Type Guide (Low Strictness)
|
|
||||||
|
|
||||||
| Relationship Type | Create People Notes? | Create Org Note? |
|
|
||||||
|-------------------|----------------------|------------------|
|
|
||||||
| Customer | Yes — all contacts | Yes |
|
|
||||||
| Prospect | Yes — all contacts | Yes |
|
|
||||||
| Investor | Yes | Yes |
|
|
||||||
| Partner | Yes — all contacts | Yes |
|
|
||||||
| Vendor | Yes — all contacts | Yes |
|
|
||||||
| Bank/Financial | Yes | Yes |
|
|
||||||
| Candidate | Yes | No |
|
|
||||||
| Recruiter | Yes | Optional |
|
|
||||||
| Service provider | Yes | Optional |
|
|
||||||
| Cold outreach | Yes | Optional |
|
|
||||||
| Support interaction | Yes | Optional |
|
|
||||||
|
|
||||||
## Organizations
|
|
||||||
|
|
||||||
**CREATE a note if:**
|
|
||||||
- Anyone from that org is mentioned or contacted you
|
|
||||||
- The org is mentioned in any context
|
|
||||||
|
|
||||||
**Only skip:**
|
|
||||||
- Organizations you genuinely can't identify
|
|
||||||
|
|
||||||
## Projects
|
|
||||||
|
|
||||||
**CREATE a note if:**
|
|
||||||
- Discussed in meeting or email
|
|
||||||
- Any indication of ongoing work or collaboration
|
|
||||||
|
|
||||||
## Topics
|
|
||||||
|
|
||||||
**CREATE a note if:**
|
|
||||||
- Mentioned more than once
|
|
||||||
- Seems like a recurring theme
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 6: Extract Content
|
|
||||||
|
|
||||||
For each entity that has or will have a note, extract relevant content.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
Extract what was decided, when, by whom, and why.
|
|
||||||
|
|
||||||
## Commitments
|
|
||||||
|
|
||||||
Extract who committed to what, and any deadlines.
|
|
||||||
|
|
||||||
## Key Facts
|
|
||||||
|
|
||||||
Key facts should be **substantive information** — not commentary about missing data.
|
|
||||||
|
|
||||||
**Extract if:**
|
|
||||||
- Specific numbers, dates, or metrics
|
|
||||||
- Preferences or working style
|
|
||||||
- Background information
|
|
||||||
- Authority or decision process
|
|
||||||
- Concerns or constraints
|
|
||||||
- What they're working on or interested in
|
|
||||||
|
|
||||||
**Never include:**
|
|
||||||
- Meta-commentary about missing data
|
|
||||||
- Obvious facts already in Info section
|
|
||||||
- Placeholder text
|
|
||||||
|
|
||||||
**If there are no substantive key facts, leave the section empty.**
|
|
||||||
|
|
||||||
## Open Items
|
|
||||||
|
|
||||||
**Include:**
|
|
||||||
- Commitments made
|
|
||||||
- Requests received
|
|
||||||
- Next steps discussed
|
|
||||||
- Follow-ups agreed
|
|
||||||
|
|
||||||
**Never include:**
|
|
||||||
- Data gaps or research tasks
|
|
||||||
- Wishes or hypotheticals
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The summary should answer: **"Who is this person and why do I know them?"**
|
|
||||||
|
|
||||||
Write 2-3 sentences covering their role/function, context of the relationship, and what you're discussing.
|
|
||||||
|
|
||||||
## Activity Summary
|
|
||||||
|
|
||||||
One line summarizing this source's relevance to the entity:
|
|
||||||
\`\`\`
|
|
||||||
**{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[links]]}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**For voice memos:** Include a link to the voice memo file using the Path field:
|
|
||||||
\`\`\`
|
|
||||||
**2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]]
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 7: Detect State Changes
|
|
||||||
|
|
||||||
Review the extracted content for signals that existing note fields should be updated.
|
|
||||||
|
|
||||||
## 7a: Project Status Changes
|
|
||||||
|
|
||||||
Look for signals like "approved", "on hold", "cancelled", "completed", etc.
|
|
||||||
|
|
||||||
## 7b: Open Item Resolution
|
|
||||||
|
|
||||||
Look for signals that tracked items are now complete.
|
|
||||||
|
|
||||||
## 7c: Role/Title Changes
|
|
||||||
|
|
||||||
Look for new titles in signatures or explicit announcements.
|
|
||||||
|
|
||||||
## 7d: Organization/Relationship Changes
|
|
||||||
|
|
||||||
Look for company changes, partnership announcements, etc.
|
|
||||||
|
|
||||||
## 7e: Build State Change List
|
|
||||||
|
|
||||||
Compile all detected state changes before writing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 8: Check for Duplicates and Conflicts
|
|
||||||
|
|
||||||
Before writing:
|
|
||||||
- Check if already processed this source
|
|
||||||
- Skip duplicate key facts
|
|
||||||
- Handle conflicting information by noting both versions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 9: Write Updates
|
|
||||||
|
|
||||||
## 9a: Create and Update Notes
|
|
||||||
|
|
||||||
**IMPORTANT: Write sequentially, one file at a time.**
|
|
||||||
- Generate content for exactly one note.
|
|
||||||
- Issue exactly one write/edit command.
|
|
||||||
- Wait for the tool to return before generating the next note.
|
|
||||||
- Do NOT batch multiple write commands in a single response.
|
|
||||||
|
|
||||||
**For NEW entities (use workspace-writeFile):**
|
|
||||||
\`\`\`
|
|
||||||
workspace-writeFile({
|
|
||||||
path: "{knowledge_folder}/People/Jennifer.md",
|
|
||||||
data: "# Jennifer\\n\\n## Summary\\n..."
|
|
||||||
})
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**For EXISTING entities (use workspace-edit):**
|
|
||||||
- Read current content first with workspace-readFile
|
|
||||||
- Use workspace-edit to add activity entry at TOP (reverse chronological)
|
|
||||||
- Update fields using targeted edits
|
|
||||||
\`\`\`
|
|
||||||
workspace-edit({
|
|
||||||
path: "{knowledge_folder}/People/Sarah Chen.md",
|
|
||||||
oldString: "## Activity\\n",
|
|
||||||
newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n"
|
|
||||||
})
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## 9b: Apply State Changes
|
|
||||||
|
|
||||||
Update all fields identified in Step 7.
|
|
||||||
|
|
||||||
## 9c: Update Aliases
|
|
||||||
|
|
||||||
Add newly discovered name variants to Aliases field.
|
|
||||||
|
|
||||||
## 9d: Writing Rules
|
|
||||||
|
|
||||||
- **Always use absolute paths** with format \`[[Folder/Name]]\` for all links
|
|
||||||
- Use YYYY-MM-DD format for dates
|
|
||||||
- Be concise: one line per activity entry
|
|
||||||
- Escape quotes properly in shell commands
|
|
||||||
- Write only one file per response (no multi-file write batches)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Step 10: Ensure Bidirectional Links
|
|
||||||
|
|
||||||
After writing, verify links go both ways.
|
|
||||||
|
|
||||||
## Absolute Link Format
|
|
||||||
|
|
||||||
**IMPORTANT:** Always use absolute links:
|
|
||||||
\`\`\`markdown
|
|
||||||
[[People/Sarah Chen]]
|
|
||||||
[[Organizations/Acme Corp]]
|
|
||||||
[[Projects/Acme Integration]]
|
|
||||||
[[Topics/Security Compliance]]
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Bidirectional Link Rules
|
|
||||||
|
|
||||||
| If you add... | Then also add... |
|
|
||||||
|---------------|------------------|
|
|
||||||
| Person → Organization | Organization → Person |
|
|
||||||
| Person → Project | Project → Person |
|
|
||||||
| Project → Organization | Organization → Project |
|
|
||||||
| Project → Topic | Topic → Project |
|
|
||||||
| Person → Person | Person → Person (reverse) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Note Templates
|
|
||||||
|
|
||||||
## People
|
|
||||||
\`\`\`markdown
|
|
||||||
# {Full Name}
|
|
||||||
|
|
||||||
## Info
|
|
||||||
**Role:** {role, inferred role, or Unknown}
|
|
||||||
**Organization:** [[Organizations/{organization}]] or leave blank
|
|
||||||
**Email:** {email or leave blank}
|
|
||||||
**Aliases:** {comma-separated: first name, nicknames, email}
|
|
||||||
**First met:** {YYYY-MM-DD}
|
|
||||||
**Last seen:** {YYYY-MM-DD}
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
{2-3 sentences: Who they are, why you know them.}
|
|
||||||
|
|
||||||
## Connected to
|
|
||||||
- [[Organizations/{Organization}]] — works at
|
|
||||||
- [[People/{Person}]] — {relationship}
|
|
||||||
- [[Projects/{Project}]] — {role}
|
|
||||||
|
|
||||||
## Activity
|
|
||||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
|
||||||
|
|
||||||
## Key facts
|
|
||||||
{Substantive facts only. Leave empty if none.}
|
|
||||||
|
|
||||||
## Open items
|
|
||||||
{Commitments and next steps only. Leave empty if none.}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Organizations
|
|
||||||
\`\`\`markdown
|
|
||||||
# {Organization Name}
|
|
||||||
|
|
||||||
## Info
|
|
||||||
**Type:** {company|team|institution|other}
|
|
||||||
**Industry:** {industry or leave blank}
|
|
||||||
**Relationship:** {customer|prospect|partner|competitor|vendor|other}
|
|
||||||
**Domain:** {primary email domain}
|
|
||||||
**Aliases:** {short names, abbreviations}
|
|
||||||
**First met:** {YYYY-MM-DD}
|
|
||||||
**Last seen:** {YYYY-MM-DD}
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
{2-3 sentences: What this org is, what your relationship is.}
|
|
||||||
|
|
||||||
## People
|
|
||||||
- [[People/{Person}]] — {role}
|
|
||||||
|
|
||||||
## Contacts
|
|
||||||
{For contacts who have their own notes}
|
|
||||||
|
|
||||||
## Projects
|
|
||||||
- [[Projects/{Project}]] — {relationship}
|
|
||||||
|
|
||||||
## Activity
|
|
||||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary}
|
|
||||||
|
|
||||||
## Key facts
|
|
||||||
{Substantive facts only. Leave empty if none.}
|
|
||||||
|
|
||||||
## Open items
|
|
||||||
{Commitments and next steps only. Leave empty if none.}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Projects
|
|
||||||
\`\`\`markdown
|
|
||||||
# {Project Name}
|
|
||||||
|
|
||||||
## Info
|
|
||||||
**Type:** {deal|product|initiative|hiring|other}
|
|
||||||
**Status:** {active|planning|on hold|completed|cancelled}
|
|
||||||
**Started:** {YYYY-MM-DD or leave blank}
|
|
||||||
**Last activity:** {YYYY-MM-DD}
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
{2-3 sentences: What this project is, goal, current state.}
|
|
||||||
|
|
||||||
## People
|
|
||||||
- [[People/{Person}]] — {role}
|
|
||||||
|
|
||||||
## Organizations
|
|
||||||
- [[Organizations/{Org}]] — {relationship}
|
|
||||||
|
|
||||||
## Related
|
|
||||||
- [[Topics/{Topic}]] — {relationship}
|
|
||||||
|
|
||||||
## Timeline
|
|
||||||
**{YYYY-MM-DD}** ({meeting|email|voice memo})
|
|
||||||
{What happened.}
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
- **{YYYY-MM-DD}**: {Decision}
|
|
||||||
|
|
||||||
## Open items
|
|
||||||
{Commitments and next steps only.}
|
|
||||||
|
|
||||||
## Key facts
|
|
||||||
{Substantive facts only.}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Topics
|
|
||||||
\`\`\`markdown
|
|
||||||
# {Topic Name}
|
|
||||||
|
|
||||||
## About
|
|
||||||
{1-2 sentences: What this topic covers.}
|
|
||||||
|
|
||||||
**Keywords:** {comma-separated}
|
|
||||||
**Aliases:** {other references}
|
|
||||||
**First mentioned:** {YYYY-MM-DD}
|
|
||||||
**Last mentioned:** {YYYY-MM-DD}
|
|
||||||
|
|
||||||
## Related
|
|
||||||
- [[People/{Person}]] — {relationship}
|
|
||||||
- [[Organizations/{Org}]] — {relationship}
|
|
||||||
- [[Projects/{Project}]] — {relationship}
|
|
||||||
|
|
||||||
## Log
|
|
||||||
**{YYYY-MM-DD}** ({meeting|email}: {title})
|
|
||||||
{Summary}
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
- **{YYYY-MM-DD}**: {Decision}
|
|
||||||
|
|
||||||
## Open items
|
|
||||||
{Commitments and next steps only.}
|
|
||||||
|
|
||||||
## Key facts
|
|
||||||
{Substantive facts only.}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Summary: Low Strictness Rules
|
|
||||||
|
|
||||||
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|
|
||||||
|-------------|---------------|----------------|------------------------|
|
|
||||||
| Meeting | Yes — ALL external attendees | Yes | Yes |
|
|
||||||
| Voice memo | Yes — all mentioned entities | Yes | Yes |
|
|
||||||
| Email (any human sender) | Yes | Yes | Yes |
|
|
||||||
| Email (automated/newsletter) | No (SKIP) | No | No |
|
|
||||||
|
|
||||||
**Voice memo activity format:** Always include a link to the source voice memo:
|
|
||||||
\`\`\`
|
|
||||||
**2025-01-15** (voice memo): Discussed project timeline with [[People/Sarah Chen]]. See [[Voice Memos/2025-01-15/voice-memo-...]]
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**Philosophy:** Capture broadly, filter later if needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Error Handling
|
|
||||||
|
|
||||||
1. **Missing data:** Leave blank or write "Unknown"
|
|
||||||
2. **Ambiguous names:** Create note with disambiguation note
|
|
||||||
3. **Conflicting info:** Note both versions
|
|
||||||
4. **grep returns nothing:** Create new notes
|
|
||||||
5. **State change unclear:** Log in activity but don't change the field
|
|
||||||
6. **Note file malformed:** Log warning, attempt partial update
|
|
||||||
7. **Shell command fails:** Log error, continue
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Quality Checklist
|
|
||||||
|
|
||||||
Before completing, verify:
|
|
||||||
|
|
||||||
**Source Type:**
|
|
||||||
- [ ] Correctly identified as meeting or email
|
|
||||||
- [ ] Applied low strictness rules (capture broadly)
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
- [ ] Extracted all name variants
|
|
||||||
- [ ] Searched existing notes
|
|
||||||
- [ ] Built resolution map
|
|
||||||
- [ ] Used absolute paths \`[[Folder/Name]]\`
|
|
||||||
|
|
||||||
**Filtering:**
|
|
||||||
- [ ] Excluded only self and @user.domain
|
|
||||||
- [ ] Created notes for all external contacts
|
|
||||||
- [ ] Only skipped obvious automated/newsletters
|
|
||||||
|
|
||||||
**Content Quality:**
|
|
||||||
- [ ] Summaries describe relationship
|
|
||||||
- [ ] Roles inferred where possible
|
|
||||||
- [ ] Key facts are substantive
|
|
||||||
- [ ] Open items are commitments/next steps
|
|
||||||
|
|
||||||
**State Changes:**
|
|
||||||
- [ ] Detected and applied state changes
|
|
||||||
- [ ] Logged changes in activity
|
|
||||||
|
|
||||||
**Structure:**
|
|
||||||
- [ ] All links use \`[[Folder/Name]]\` format
|
|
||||||
- [ ] Activity entries reverse chronological
|
|
||||||
- [ ] Dates are YYYY-MM-DD
|
|
||||||
- [ ] Bidirectional links consistent
|
|
||||||
`;
|
|
||||||
202
apps/x/packages/core/src/knowledge/note_system.ts
Normal file
202
apps/x/packages/core/src/knowledge/note_system.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import { WorkDir } from "../config/config.js";
|
||||||
|
|
||||||
|
export interface NoteTypeDefinition {
|
||||||
|
type: string;
|
||||||
|
folder: string;
|
||||||
|
template: string;
|
||||||
|
extractionGuide: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default definitions (used to seed ~/.rowboat/config/notes.json) ──────────
|
||||||
|
|
||||||
|
const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
|
||||||
|
{
|
||||||
|
type: "People",
|
||||||
|
folder: "People",
|
||||||
|
template: `# {Full Name}
|
||||||
|
|
||||||
|
## Info
|
||||||
|
**Role:** {role, or inferred role with qualifier, or leave blank if truly unknown}
|
||||||
|
**Organization:** [[Organizations/{organization}]] or leave blank
|
||||||
|
**Email:** {email or leave blank}
|
||||||
|
**Aliases:** {comma-separated: first name, nicknames, email}
|
||||||
|
**First met:** {YYYY-MM-DD}
|
||||||
|
**Last seen:** {YYYY-MM-DD}
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
{2-3 sentences: Who they are, why you know them, what you're working on together.}
|
||||||
|
|
||||||
|
## Connected to
|
||||||
|
- [[Organizations/{Organization}]] — works at
|
||||||
|
- [[People/{Person}]] — {colleague, introduced by, reports to}
|
||||||
|
- [[Projects/{Project}]] — {role}
|
||||||
|
|
||||||
|
## Activity
|
||||||
|
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
||||||
|
|
||||||
|
## Key facts
|
||||||
|
{Substantive facts only. Leave empty if none.}
|
||||||
|
|
||||||
|
## Open items
|
||||||
|
{Commitments and next steps only. Leave empty if none.}`,
|
||||||
|
extractionGuide:
|
||||||
|
"Look for: name, role, organization, email, aliases, relationship context",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Organizations",
|
||||||
|
folder: "Organizations",
|
||||||
|
template: `# {Organization Name}
|
||||||
|
|
||||||
|
## Info
|
||||||
|
**Type:** {company|team|institution|other}
|
||||||
|
**Industry:** {industry or leave blank}
|
||||||
|
**Relationship:** {customer|prospect|partner|competitor|vendor|other}
|
||||||
|
**Domain:** {primary email domain}
|
||||||
|
**Aliases:** {comma-separated: short names, abbreviations}
|
||||||
|
**First met:** {YYYY-MM-DD}
|
||||||
|
**Last seen:** {YYYY-MM-DD}
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
{2-3 sentences: What this org is, what your relationship is.}
|
||||||
|
|
||||||
|
## People
|
||||||
|
- [[People/{Person}]] — {role}
|
||||||
|
|
||||||
|
## Contacts
|
||||||
|
{For transactional contacts who don't get their own notes}
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
- [[Projects/{Project}]] — {relationship}
|
||||||
|
|
||||||
|
## Activity
|
||||||
|
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
||||||
|
|
||||||
|
## Key facts
|
||||||
|
{Substantive facts only. Leave empty if none.}
|
||||||
|
|
||||||
|
## Open items
|
||||||
|
{Commitments and next steps only. Leave empty if none.}`,
|
||||||
|
extractionGuide:
|
||||||
|
"Look for: organization name, type, industry, relationship, domain, key people, projects",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Projects",
|
||||||
|
folder: "Projects",
|
||||||
|
template: `# {Project Name}
|
||||||
|
|
||||||
|
## Info
|
||||||
|
**Type:** {deal|product|initiative|hiring|other}
|
||||||
|
**Status:** {active|planning|on hold|completed|cancelled}
|
||||||
|
**Started:** {YYYY-MM-DD or leave blank}
|
||||||
|
**Last activity:** {YYYY-MM-DD}
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
{2-3 sentences: What this project is, goal, current state.}
|
||||||
|
|
||||||
|
## People
|
||||||
|
- [[People/{Person}]] — {role}
|
||||||
|
|
||||||
|
## Organizations
|
||||||
|
- [[Organizations/{Org}]] — {customer|partner|etc.}
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- [[Topics/{Topic}]] — {relationship}
|
||||||
|
- [[Projects/{Project}]] — {relationship}
|
||||||
|
|
||||||
|
## Timeline
|
||||||
|
**{YYYY-MM-DD}** ({meeting|email})
|
||||||
|
{What happened.}
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
- **{YYYY-MM-DD}**: {Decision}. {Rationale}.
|
||||||
|
|
||||||
|
## Open items
|
||||||
|
{Commitments and next steps only. Leave empty if none.}
|
||||||
|
|
||||||
|
## Key facts
|
||||||
|
{Substantive facts only. Leave empty if none.}`,
|
||||||
|
extractionGuide:
|
||||||
|
"Look for: project name, type, status, people involved, organizations, timeline, decisions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Topics",
|
||||||
|
folder: "Topics",
|
||||||
|
template: `# {Topic Name}
|
||||||
|
|
||||||
|
## About
|
||||||
|
{1-2 sentences: What this topic covers.}
|
||||||
|
|
||||||
|
**Keywords:** {comma-separated}
|
||||||
|
**Aliases:** {other ways this topic is referenced}
|
||||||
|
**First mentioned:** {YYYY-MM-DD}
|
||||||
|
**Last mentioned:** {YYYY-MM-DD}
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- [[People/{Person}]] — {relationship}
|
||||||
|
- [[Organizations/{Org}]] — {relationship}
|
||||||
|
- [[Projects/{Project}]] — {relationship}
|
||||||
|
|
||||||
|
## Log
|
||||||
|
**{YYYY-MM-DD}** ({meeting|email}: {title})
|
||||||
|
{Summary with [[Folder/Name]] links}
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
- **{YYYY-MM-DD}**: {Decision}
|
||||||
|
|
||||||
|
## Open items
|
||||||
|
{Commitments and next steps only. Leave empty if none.}
|
||||||
|
|
||||||
|
## Key facts
|
||||||
|
{Substantive facts only. Leave empty if none.}`,
|
||||||
|
extractionGuide:
|
||||||
|
"Look for: topic name, keywords, related people/orgs/projects, decisions, key facts",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Disk-backed config with mtime caching ──────────────────────────────────
|
||||||
|
|
||||||
|
export const NOTES_CONFIG_PATH = path.join(WorkDir, "config", "notes.json");
|
||||||
|
|
||||||
|
let cachedNoteTypeDefinitions: NoteTypeDefinition[] | null = null;
|
||||||
|
let cachedMtimeMs: number | null = null;
|
||||||
|
|
||||||
|
function ensureNotesConfigSync(): void {
|
||||||
|
if (!fs.existsSync(NOTES_CONFIG_PATH)) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
NOTES_CONFIG_PATH,
|
||||||
|
JSON.stringify(DEFAULT_NOTE_TYPE_DEFINITIONS, null, 2) + "\n",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNoteTypeDefinitions(): NoteTypeDefinition[] {
|
||||||
|
ensureNotesConfigSync();
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(NOTES_CONFIG_PATH);
|
||||||
|
if (cachedNoteTypeDefinitions && cachedMtimeMs === stats.mtimeMs) {
|
||||||
|
return cachedNoteTypeDefinitions;
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(NOTES_CONFIG_PATH, "utf8");
|
||||||
|
cachedNoteTypeDefinitions = JSON.parse(content);
|
||||||
|
cachedMtimeMs = stats.mtimeMs;
|
||||||
|
return cachedNoteTypeDefinitions!;
|
||||||
|
} catch {
|
||||||
|
cachedNoteTypeDefinitions = null;
|
||||||
|
cachedMtimeMs = null;
|
||||||
|
return DEFAULT_NOTE_TYPE_DEFINITIONS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render helper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function renderNoteTypesBlock(): string {
|
||||||
|
const defs = getNoteTypeDefinitions();
|
||||||
|
const sections = defs.map(
|
||||||
|
(d) =>
|
||||||
|
`## ${d.type}\n\`\`\`markdown\n${d.template}\n\`\`\``,
|
||||||
|
);
|
||||||
|
return `# Note Templates\n\n${sections.join("\n\n")}`;
|
||||||
|
}
|
||||||
132
apps/x/packages/core/src/knowledge/note_tagging_agent.ts
Normal file
132
apps/x/packages/core/src/knowledge/note_tagging_agent.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { renderTagSystemForNotes } from './tag_system.js';
|
||||||
|
|
||||||
|
export function getRaw(): string {
|
||||||
|
return `---
|
||||||
|
model: gpt-5.2
|
||||||
|
tools:
|
||||||
|
workspace-readFile:
|
||||||
|
type: builtin
|
||||||
|
name: workspace-readFile
|
||||||
|
workspace-edit:
|
||||||
|
type: builtin
|
||||||
|
name: workspace-edit
|
||||||
|
workspace-readdir:
|
||||||
|
type: builtin
|
||||||
|
name: workspace-readdir
|
||||||
|
---
|
||||||
|
# Task
|
||||||
|
|
||||||
|
You are a note tagging agent. Given a batch of knowledge notes (People, Organizations, Projects, Topics), you will classify each note and prepend YAML frontmatter with categorized tags and Info attributes.
|
||||||
|
|
||||||
|
# Instructions
|
||||||
|
|
||||||
|
1. For each note file provided in the message, read its content carefully.
|
||||||
|
2. Determine the note type from its folder path (People/, Organizations/, Projects/, Topics/).
|
||||||
|
3. Classify the note using the Rowboat Tag System (Note Tags section) appended below.
|
||||||
|
4. Extract attributes from the note's \`## Info\` section (or \`## About\` for Topics).
|
||||||
|
5. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Title\` heading), and the newString should be the frontmatter followed by that same first line.
|
||||||
|
6. If the note already has frontmatter (starts with \`---\`), skip it.
|
||||||
|
|
||||||
|
# Frontmatter Format
|
||||||
|
|
||||||
|
Tags are organized by **category** (not a flat list). Each tag category is a top-level YAML key. Use a plain string for single values, or a YAML list for multiple values.
|
||||||
|
|
||||||
|
Info attributes from the \`## Info\` section are also included as top-level keys.
|
||||||
|
|
||||||
|
\`\`\`yaml
|
||||||
|
---
|
||||||
|
relationship: customer
|
||||||
|
relationship_sub: primary
|
||||||
|
topic:
|
||||||
|
- sales
|
||||||
|
- fundraising
|
||||||
|
source: email
|
||||||
|
status: active
|
||||||
|
action: action-required
|
||||||
|
role: VP Engineering
|
||||||
|
organization: Acme Corp
|
||||||
|
email: sarah@acme.com
|
||||||
|
first_met: "2024-06-15"
|
||||||
|
last_seen: "2025-01-20"
|
||||||
|
---
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Tag category keys
|
||||||
|
|
||||||
|
Use these exact keys for each tag category:
|
||||||
|
|
||||||
|
| Category | Key | Single or multi | Example |
|
||||||
|
|----------|-----|-----------------|---------|
|
||||||
|
| Relationship | \`relationship\` | single | \`relationship: customer\` |
|
||||||
|
| Relationship sub | \`relationship_sub\` | single or multi | \`relationship_sub: primary\` |
|
||||||
|
| Topic | \`topic\` | single or multi | \`topic: sales\` or list |
|
||||||
|
| Email type | \`email_type\` | single or multi | \`email_type: followup\` |
|
||||||
|
| Action | \`action\` | single or multi | \`action: action-required\` |
|
||||||
|
| Status | \`status\` | single | \`status: active\` |
|
||||||
|
| Source | \`source\` | single or multi | \`source: email\` or list |
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Use a plain string when there's only one value: \`topic: sales\`
|
||||||
|
- Use a YAML list when there are multiple values:
|
||||||
|
\`\`\`yaml
|
||||||
|
topic:
|
||||||
|
- sales
|
||||||
|
- fundraising
|
||||||
|
\`\`\`
|
||||||
|
- **Omit a category entirely** if no tags apply for it. Do not include empty keys.
|
||||||
|
- Only use tag values from the Rowboat Tag System — do not invent new tags.
|
||||||
|
|
||||||
|
# Info Attribute Extraction Rules
|
||||||
|
|
||||||
|
Extract all \`**Key:** value\` fields from the \`## Info\` (or \`## About\`) section into YAML frontmatter keys:
|
||||||
|
|
||||||
|
1. **Convert keys to snake_case**: e.g. \`**First met:**\` → \`first_met\`, \`**Last activity:**\` → \`last_activity\`, \`**Last seen:**\` → \`last_seen\`.
|
||||||
|
2. **Strip wiki-link syntax**: \`[[Organizations/Acme Corp]]\` → \`Acme Corp\`. Extract just the display name (last path segment).
|
||||||
|
3. **Skip blank/placeholder values**: If a field says "leave blank", is empty, or contains only template placeholders like \`{role}\`, omit it from the frontmatter.
|
||||||
|
4. **Quote dates**: Wrap date values in quotes, e.g. \`first_met: "2024-06-15"\`.
|
||||||
|
5. **Aliases as list**: If the value is comma-separated (like Aliases), store as a YAML list:
|
||||||
|
\`\`\`yaml
|
||||||
|
aliases:
|
||||||
|
- Sarah
|
||||||
|
- sarah@acme.com
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
**Per note type, extract these fields:**
|
||||||
|
|
||||||
|
- **People**: role, organization, email, aliases, first_met, last_seen
|
||||||
|
- **Organizations**: type, industry, relationship, domain, aliases, first_met, last_seen
|
||||||
|
- **Projects**: type, status, started, last_activity
|
||||||
|
- **Topics** (from \`## About\`): keywords, aliases, first_mentioned, last_mentioned
|
||||||
|
|
||||||
|
Note: For Organizations, the Info \`**Relationship:**\` field is separate from the \`relationship\` tag category. Include both — the Info field as \`info_relationship\` and the tag as \`relationship\`.
|
||||||
|
|
||||||
|
# Tag Selection Rules
|
||||||
|
|
||||||
|
1. **Always include at least one relationship or topic tag** — every note must be classifiable.
|
||||||
|
2. **Always include a source tag** — \`email\` or \`meeting\` based on what the note's Activity section shows.
|
||||||
|
3. **Default status is \`active\`** for all new tags.
|
||||||
|
4. **For People notes**, include:
|
||||||
|
- One primary relationship tag (e.g. \`customer\`, \`investor\`, \`prospect\`)
|
||||||
|
- Relationship sub-tags if applicable (e.g. \`primary\`, \`champion\`, \`former\`)
|
||||||
|
- Topic tags based on what you're working on together
|
||||||
|
- Source tags based on the Activity section
|
||||||
|
- Action tags if there are open items
|
||||||
|
5. **For Organization notes**, include:
|
||||||
|
- One primary relationship tag
|
||||||
|
- Topic tags based on the relationship context
|
||||||
|
- Source tags
|
||||||
|
6. **For Project notes**, include:
|
||||||
|
- Topic tags based on project type
|
||||||
|
- Source tags
|
||||||
|
- Action tags if there are open items
|
||||||
|
7. **For Topic notes**, include:
|
||||||
|
- The relevant topic tag
|
||||||
|
- Source tags
|
||||||
|
8. **Only use tags from the Rowboat Tag System** — do not invent new tags.
|
||||||
|
9. Process all files in the batch. Do not skip any unless they already have frontmatter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
${renderTagSystemForNotes()}
|
||||||
|
`;
|
||||||
|
}
|
||||||
48
apps/x/packages/core/src/knowledge/note_tagging_state.ts
Normal file
48
apps/x/packages/core/src/knowledge/note_tagging_state.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { WorkDir } from '../config/config.js';
|
||||||
|
|
||||||
|
const STATE_FILE = path.join(WorkDir, 'note_tagging_state.json');
|
||||||
|
|
||||||
|
export interface NoteTaggingState {
|
||||||
|
processedFiles: Record<string, { taggedAt: string }>;
|
||||||
|
lastRunTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadNoteTaggingState(): NoteTaggingState {
|
||||||
|
if (fs.existsSync(STATE_FILE)) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading note tagging state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
processedFiles: {},
|
||||||
|
lastRunTime: new Date(0).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveNoteTaggingState(state: NoteTaggingState): void {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving note tagging state:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markNoteAsTagged(filePath: string, state: NoteTaggingState): void {
|
||||||
|
state.processedFiles[filePath] = {
|
||||||
|
taggedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetNoteTaggingState(): void {
|
||||||
|
const emptyState: NoteTaggingState = {
|
||||||
|
processedFiles: {},
|
||||||
|
lastRunTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
saveNoteTaggingState(emptyState);
|
||||||
|
}
|
||||||
274
apps/x/packages/core/src/knowledge/tag_notes.ts
Normal file
274
apps/x/packages/core/src/knowledge/tag_notes.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { WorkDir } from '../config/config.js';
|
||||||
|
import { createRun, createMessage } from '../runs/runs.js';
|
||||||
|
import { bus } from '../runs/bus.js';
|
||||||
|
import { serviceLogger } from '../services/service_logger.js';
|
||||||
|
import { limitEventItems } from './limit_event_items.js';
|
||||||
|
import {
|
||||||
|
loadNoteTaggingState,
|
||||||
|
saveNoteTaggingState,
|
||||||
|
markNoteAsTagged,
|
||||||
|
type NoteTaggingState,
|
||||||
|
} from './note_tagging_state.js';
|
||||||
|
import { getNoteTypeDefinitions } from './note_system.js';
|
||||||
|
|
||||||
|
const SYNC_INTERVAL_MS = 30 * 1000; // 30 seconds
|
||||||
|
const BATCH_SIZE = 15;
|
||||||
|
const NOTE_TAGGING_AGENT = 'note_tagging_agent';
|
||||||
|
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||||
|
const MAX_CONTENT_LENGTH = 8000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find knowledge notes that haven't been tagged yet
|
||||||
|
*/
|
||||||
|
function getUntaggedNotes(state: NoteTaggingState): string[] {
|
||||||
|
if (!fs.existsSync(KNOWLEDGE_DIR)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const untagged: string[] = [];
|
||||||
|
const noteFolders = getNoteTypeDefinitions().map(d => d.folder);
|
||||||
|
|
||||||
|
for (const folder of noteFolders) {
|
||||||
|
const folderPath = path.join(KNOWLEDGE_DIR, folder);
|
||||||
|
if (!fs.existsSync(folderPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(folderPath);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(folderPath, entry);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
|
||||||
|
if (!stat.isFile() || !entry.endsWith('.md')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already tracked in state
|
||||||
|
if (state.processedFiles[fullPath]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if file already has frontmatter
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||||
|
if (content.startsWith('---')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
untagged.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return untagged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a run to complete by listening for run-processing-end event
|
||||||
|
*/
|
||||||
|
async function waitForRunCompletion(runId: string): Promise<void> {
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
const unsubscribe = await bus.subscribe('*', async (event) => {
|
||||||
|
if (event.type === 'run-processing-end' && event.runId === runId) {
|
||||||
|
unsubscribe();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag a batch of note files using the tagging agent
|
||||||
|
*/
|
||||||
|
async function tagNoteBatch(
|
||||||
|
files: { path: string; content: string }[]
|
||||||
|
): Promise<{ runId: string; filesEdited: Set<string> }> {
|
||||||
|
const run = await createRun({
|
||||||
|
agentId: NOTE_TAGGING_AGENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
let message = `Tag the following ${files.length} knowledge notes by prepending YAML frontmatter with appropriate tags.\n\n`;
|
||||||
|
message += `**Important:** Use workspace-relative paths with workspace-edit (e.g. "knowledge/People/Sarah Chen.md", NOT absolute paths).\n\n`;
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
const relativePath = path.relative(WorkDir, file.path);
|
||||||
|
const truncated = file.content.length > MAX_CONTENT_LENGTH
|
||||||
|
? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use workspace-readFile for full content ...]'
|
||||||
|
: file.content;
|
||||||
|
|
||||||
|
message += `## File ${i + 1}: ${relativePath}\n\n`;
|
||||||
|
message += truncated;
|
||||||
|
message += `\n\n---\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesEdited = new Set<string>();
|
||||||
|
|
||||||
|
const unsubscribe = await bus.subscribe(run.id, async (event) => {
|
||||||
|
if (event.type !== 'tool-invocation') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.toolName !== 'workspace-edit') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.input) as { path?: string };
|
||||||
|
if (typeof parsed.path === 'string') {
|
||||||
|
filesEdited.add(parsed.path);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMessage(run.id, message);
|
||||||
|
await waitForRunCompletion(run.id);
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
return { runId: run.id, filesEdited };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all untagged notes in batches
|
||||||
|
*/
|
||||||
|
async function processUntaggedNotes(): Promise<void> {
|
||||||
|
console.log('[NoteTagging] Checking for untagged notes...');
|
||||||
|
|
||||||
|
const state = loadNoteTaggingState();
|
||||||
|
const untagged = getUntaggedNotes(state);
|
||||||
|
|
||||||
|
if (untagged.length === 0) {
|
||||||
|
console.log('[NoteTagging] No untagged notes found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[NoteTagging] Found ${untagged.length} untagged notes`);
|
||||||
|
|
||||||
|
const run = await serviceLogger.startRun({
|
||||||
|
service: 'note_tagging',
|
||||||
|
message: `Tagging ${untagged.length} note${untagged.length === 1 ? '' : 's'}`,
|
||||||
|
trigger: 'timer',
|
||||||
|
});
|
||||||
|
|
||||||
|
const relativeFiles = untagged.map(f => path.relative(WorkDir, f));
|
||||||
|
const limitedFiles = limitEventItems(relativeFiles);
|
||||||
|
await serviceLogger.log({
|
||||||
|
type: 'changes_identified',
|
||||||
|
service: run.service,
|
||||||
|
runId: run.runId,
|
||||||
|
level: 'info',
|
||||||
|
message: `Found ${untagged.length} untagged note${untagged.length === 1 ? '' : 's'}`,
|
||||||
|
counts: { notes: untagged.length },
|
||||||
|
items: limitedFiles.items,
|
||||||
|
truncated: limitedFiles.truncated,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalBatches = Math.ceil(untagged.length / BATCH_SIZE);
|
||||||
|
let totalEdited = 0;
|
||||||
|
let hadError = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < untagged.length; i += BATCH_SIZE) {
|
||||||
|
const batchPaths = untagged.slice(i, i + BATCH_SIZE);
|
||||||
|
const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files: { path: string; content: string }[] = [];
|
||||||
|
for (const filePath of batchPaths) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
files.push({ path: filePath, content });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[NoteTagging] Error reading ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[NoteTagging] Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`);
|
||||||
|
await serviceLogger.log({
|
||||||
|
type: 'progress',
|
||||||
|
service: run.service,
|
||||||
|
runId: run.runId,
|
||||||
|
level: 'info',
|
||||||
|
message: `Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`,
|
||||||
|
step: 'batch',
|
||||||
|
current: batchNumber,
|
||||||
|
total: totalBatches,
|
||||||
|
details: { filesInBatch: files.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tagNoteBatch(files);
|
||||||
|
totalEdited += result.filesEdited.size;
|
||||||
|
|
||||||
|
// Only mark files that were actually edited by the agent
|
||||||
|
for (const file of files) {
|
||||||
|
const relativePath = path.relative(WorkDir, file.path);
|
||||||
|
if (result.filesEdited.has(relativePath)) {
|
||||||
|
markNoteAsTagged(file.path, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveNoteTaggingState(state);
|
||||||
|
console.log(`[NoteTagging] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files tagged`);
|
||||||
|
} catch (error) {
|
||||||
|
hadError = true;
|
||||||
|
console.error(`[NoteTagging] Error processing batch ${batchNumber}:`, error);
|
||||||
|
await serviceLogger.log({
|
||||||
|
type: 'error',
|
||||||
|
service: run.service,
|
||||||
|
runId: run.runId,
|
||||||
|
level: 'error',
|
||||||
|
message: `Error processing batch ${batchNumber}`,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
context: { batchNumber },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lastRunTime = new Date().toISOString();
|
||||||
|
saveNoteTaggingState(state);
|
||||||
|
|
||||||
|
await serviceLogger.log({
|
||||||
|
type: 'run_complete',
|
||||||
|
service: run.service,
|
||||||
|
runId: run.runId,
|
||||||
|
level: hadError ? 'error' : 'info',
|
||||||
|
message: `Note tagging complete: ${totalEdited} notes tagged`,
|
||||||
|
durationMs: Date.now() - run.startedAt,
|
||||||
|
outcome: hadError ? 'error' : 'ok',
|
||||||
|
summary: {
|
||||||
|
totalNotes: untagged.length,
|
||||||
|
notesTagged: totalEdited,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[NoteTagging] Done. ${totalEdited} notes tagged.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point - runs as independent polling service
|
||||||
|
*/
|
||||||
|
export async function init() {
|
||||||
|
console.log('[NoteTagging] Starting Note Tagging Service...');
|
||||||
|
console.log(`[NoteTagging] Will check for untagged notes every ${SYNC_INTERVAL_MS / 1000} seconds`);
|
||||||
|
|
||||||
|
// Initial run
|
||||||
|
await processUntaggedNotes();
|
||||||
|
|
||||||
|
// Periodic polling
|
||||||
|
while (true) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await processUntaggedNotes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[NoteTagging] Error in main loop:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
230
apps/x/packages/core/src/knowledge/tag_system.ts
Normal file
230
apps/x/packages/core/src/knowledge/tag_system.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import { WorkDir } from "../config/config.js";
|
||||||
|
|
||||||
|
export type TagApplicability = 'email' | 'notes' | 'both';
|
||||||
|
|
||||||
|
export type TagType =
|
||||||
|
| 'relationship'
|
||||||
|
| 'relationship-sub'
|
||||||
|
| 'topic'
|
||||||
|
| 'email-type'
|
||||||
|
| 'filter'
|
||||||
|
| 'action'
|
||||||
|
| 'status'
|
||||||
|
| 'source';
|
||||||
|
|
||||||
|
export type NoteEffect = 'create' | 'skip' | 'none';
|
||||||
|
|
||||||
|
export interface TagDefinition {
|
||||||
|
tag: string;
|
||||||
|
type: TagType;
|
||||||
|
applicability: TagApplicability;
|
||||||
|
description: string;
|
||||||
|
example?: string;
|
||||||
|
/** Whether an email with this tag should create notes ('create'), be skipped ('skip'), or has no effect on note creation ('none'). */
|
||||||
|
noteEffect?: NoteEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default definitions (used to seed ~/.rowboat/config/tags.json) ──────────
|
||||||
|
|
||||||
|
const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [
|
||||||
|
// ── Relationship (both) ──────────────────────────────────────────────
|
||||||
|
{ tag: 'investor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Investors, VCs, or angels', example: 'Following up on our meeting — we\'d like to move forward with the Series A term sheet.' },
|
||||||
|
{ tag: 'customer', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Paying customers', example: 'We\'re seeing great results with Rowboat. Can we discuss expanding to more teams?' },
|
||||||
|
{ tag: 'prospect', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Potential customers', example: 'Thanks for the demo yesterday. We\'re interested in starting a pilot.' },
|
||||||
|
{ tag: 'partner', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Business partners', example: 'Let\'s discuss how we can promote the integration to both our user bases.' },
|
||||||
|
{ tag: 'vendor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Service providers you work with', example: 'Here are the updated employment agreements you requested.' },
|
||||||
|
{ tag: 'product', type: 'relationship', applicability: 'both', noteEffect: 'skip', description: 'Products or services you use (automated)', example: 'Your AWS bill for January 2025 is now available.' },
|
||||||
|
{ tag: 'candidate', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Job applicants', example: 'Thanks for reaching out. I\'d love to learn more about the engineering role.' },
|
||||||
|
{ tag: 'team', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Internal team members', example: 'Here\'s the updated roadmap for Q2. Let\'s discuss in our sync.' },
|
||||||
|
{ tag: 'advisor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Advisors, mentors, or board members', example: 'I\'ve reviewed the deck. Here are my thoughts on the GTM strategy.' },
|
||||||
|
{ tag: 'personal', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Family or friends', example: 'Are you coming to Thanksgiving this year? Let me know your travel dates.' },
|
||||||
|
{ tag: 'press', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Journalists or media', example: 'I\'m writing a piece on AI agents. Would you be available for an interview?' },
|
||||||
|
{ tag: 'community', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Users, peers, or open source contributors', example: 'Love what you\'re building with Rowboat. Here\'s a bug I found...' },
|
||||||
|
{ tag: 'government', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Government agencies', example: 'Your Delaware franchise tax is due by March 1, 2025.' },
|
||||||
|
|
||||||
|
// ── Relationship Sub-Tags (notes only) ───────────────────────────────
|
||||||
|
{ tag: 'primary', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Main contact or decision maker', example: 'Sarah Chen — VP Engineering, your main point of contact at Acme.' },
|
||||||
|
{ tag: 'secondary', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Supporting contact, involved but not the lead', example: 'David Kim — Engineer CC\'d on customer emails.' },
|
||||||
|
{ tag: 'executive-assistant', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'EA or admin handling scheduling and logistics', example: 'Lisa — Sarah\'s EA who schedules all her meetings.' },
|
||||||
|
{ tag: 'cc', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Person who\'s CC\'d but not actively engaged', example: 'Manager looped in for visibility on deal.' },
|
||||||
|
{ tag: 'referred-by', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Person who made an introduction or referral', example: 'David Park — Investor who intro\'d you to Sarah.' },
|
||||||
|
{ tag: 'former', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Previously held this relationship, no longer active', example: 'John — Former customer who churned last year.' },
|
||||||
|
{ tag: 'champion', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Internal advocate pushing for you', example: 'Engineer who loves your product and is selling internally.' },
|
||||||
|
{ tag: 'blocker', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Person opposing or blocking progress', example: 'CFO resistant to spending on new tools.' },
|
||||||
|
|
||||||
|
// ── Topic (both) ─────────────────────────────────────────────────────
|
||||||
|
{ tag: 'sales', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Sales conversations, deals, and revenue', example: 'Here\'s the pricing proposal we discussed. Let me know if you have questions.' },
|
||||||
|
{ tag: 'support', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Help requests, issues, and customer support', example: 'We\'re seeing an error when trying to export. Can you help?' },
|
||||||
|
{ tag: 'legal', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Contracts, terms, compliance, and legal matters', example: 'Legal has reviewed the MSA. Attached are our requested changes.' },
|
||||||
|
{ tag: 'finance', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Money, invoices, payments, banking, and taxes', example: 'Your invoice #1234 for $5,000 is attached. Payment due in 30 days.' },
|
||||||
|
{ tag: 'hiring', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Recruiting, interviews, and employment', example: 'We\'d like to move forward with a final round interview. Are you available Thursday?' },
|
||||||
|
{ tag: 'fundraising', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Raising money and investor relations', example: 'Thanks for sending the deck. We\'d like to schedule a partner meeting.' },
|
||||||
|
{ tag: 'travel', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Flights, hotels, trips, and travel logistics', example: 'Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123.' },
|
||||||
|
{ tag: 'event', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Conferences, meetups, and gatherings', example: 'You\'re invited to speak at TechCrunch Disrupt. Can you confirm your availability?' },
|
||||||
|
{ tag: 'shopping', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Purchases, orders, and returns', example: 'Your order #12345 has shipped. Track it here.' },
|
||||||
|
{ tag: 'health', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Medical, wellness, and health-related matters', example: 'Your appointment with Dr. Smith is confirmed for Monday at 2pm.' },
|
||||||
|
{ tag: 'learning', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Courses, education, and skill-building', example: 'Welcome to the Advanced Python course. Here\'s your access link.' },
|
||||||
|
{ tag: 'research', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Research requests and information gathering', example: 'Here\'s the market analysis you requested on the AI agent space.' },
|
||||||
|
|
||||||
|
// ── Email Type ───────────────────────────────────────────────────────
|
||||||
|
{ tag: 'intro', type: 'email-type', applicability: 'both', noteEffect: 'create', description: 'Warm introduction from someone you know', example: 'I\'d like to introduce you to Sarah Chen, VP Engineering at Acme.' },
|
||||||
|
{ tag: 'followup', type: 'email-type', applicability: 'both', noteEffect: 'create', description: 'Following up on a previous conversation', example: 'Following up on our call last week. Have you had a chance to review the proposal?' },
|
||||||
|
{ tag: 'scheduling', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Meeting and calendar scheduling', example: 'Are you available for a call next Tuesday at 2pm?' },
|
||||||
|
{ tag: 'cold-outreach', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Unsolicited contact from someone you don\'t know', example: 'Hi, I noticed your company is growing fast. I\'d love to show you how we can help with...' },
|
||||||
|
{ tag: 'newsletter', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Newsletters, marketing emails, and subscriptions', example: 'This week in AI: The latest developments in agent frameworks...' },
|
||||||
|
{ tag: 'notification', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Automated alerts, receipts, and system notifications', example: 'Your password was changed successfully. If this wasn\'t you, contact support.' },
|
||||||
|
|
||||||
|
// ── Filter (email only) ──────────────────────────────────────────────
|
||||||
|
{ tag: 'spam', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Junk and unwanted email', example: 'Congratulations! You\'ve won $1,000,000...' },
|
||||||
|
{ tag: 'promotion', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Marketing offers and sales pitches', example: '50% off all items this weekend only!' },
|
||||||
|
{ tag: 'social', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Social media notifications', example: 'John Smith commented on your post.' },
|
||||||
|
{ tag: 'forums', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Mailing lists and group discussions', example: 'Re: [dev-list] Question about API design' },
|
||||||
|
|
||||||
|
// ── Action ───────────────────────────────────────────────────────────
|
||||||
|
{ tag: 'action-required', type: 'action', applicability: 'both', noteEffect: 'create', description: 'Needs a response or action from you', example: 'Can you send me the pricing by Friday?' },
|
||||||
|
{ tag: 'fyi', type: 'action', applicability: 'email', noteEffect: 'skip', description: 'Informational only, no action needed', example: 'Just wanted to let you know the deal closed. Thanks for your help!' },
|
||||||
|
{ tag: 'urgent', type: 'action', applicability: 'both', noteEffect: 'create', description: 'Time-sensitive, needs immediate attention', example: 'We need your signature on the contract by EOD today or we lose the deal.' },
|
||||||
|
{ tag: 'waiting', type: 'action', applicability: 'both', noteEffect: 'create', description: 'Waiting on a response from them' },
|
||||||
|
|
||||||
|
// ── Status (email) ───────────────────────────────────────────────────
|
||||||
|
{ tag: 'unread', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Not yet processed' },
|
||||||
|
{ tag: 'to-reply', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Need to respond' },
|
||||||
|
{ tag: 'done', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Handled, can be archived' },
|
||||||
|
|
||||||
|
// ── Source (notes only) ──────────────────────────────────────────────
|
||||||
|
{ tag: 'email', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Created or updated from email' },
|
||||||
|
{ tag: 'meeting', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Created or updated from meeting transcript' },
|
||||||
|
{ tag: 'browser', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Content captured from web browsing' },
|
||||||
|
{ tag: 'web-search', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Information from web search' },
|
||||||
|
{ tag: 'manual', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Manually entered by user' },
|
||||||
|
{ tag: 'import', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Imported from another system' },
|
||||||
|
|
||||||
|
// ── Status (notes) ──────────────────────────────────────────────────
|
||||||
|
{ tag: 'active', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'Currently relevant, recent activity' },
|
||||||
|
{ tag: 'archived', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'No longer active, kept for reference' },
|
||||||
|
{ tag: 'stale', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'No activity in 60+ days, needs attention or archive' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Disk-backed config with mtime caching ──────────────────────────────────
|
||||||
|
|
||||||
|
export const TAGS_CONFIG_PATH = path.join(WorkDir, "config", "tags.json");
|
||||||
|
|
||||||
|
let cachedTagDefinitions: TagDefinition[] | null = null;
|
||||||
|
let cachedMtimeMs: number | null = null;
|
||||||
|
|
||||||
|
function ensureTagsConfigSync(): void {
|
||||||
|
if (!fs.existsSync(TAGS_CONFIG_PATH)) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
TAGS_CONFIG_PATH,
|
||||||
|
JSON.stringify(DEFAULT_TAG_DEFINITIONS, null, 2) + "\n",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTagDefinitions(): TagDefinition[] {
|
||||||
|
ensureTagsConfigSync();
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(TAGS_CONFIG_PATH);
|
||||||
|
if (cachedTagDefinitions && cachedMtimeMs === stats.mtimeMs) {
|
||||||
|
return cachedTagDefinitions;
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(TAGS_CONFIG_PATH, "utf8");
|
||||||
|
cachedTagDefinitions = JSON.parse(content);
|
||||||
|
cachedMtimeMs = stats.mtimeMs;
|
||||||
|
return cachedTagDefinitions!;
|
||||||
|
} catch {
|
||||||
|
cachedTagDefinitions = null;
|
||||||
|
cachedMtimeMs = null;
|
||||||
|
return DEFAULT_TAG_DEFINITIONS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TYPE_ORDER: TagType[] = [
|
||||||
|
'relationship', 'relationship-sub', 'topic', 'email-type',
|
||||||
|
'filter', 'action', 'status', 'source',
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<TagType, string> = {
|
||||||
|
'relationship': 'Relationship',
|
||||||
|
'relationship-sub': 'Relationship Sub-Tags',
|
||||||
|
'topic': 'Topic',
|
||||||
|
'email-type': 'Email Type',
|
||||||
|
'filter': 'Filter',
|
||||||
|
'action': 'Action',
|
||||||
|
'status': 'Status',
|
||||||
|
'source': 'Source',
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderTagGroups(tags: TagDefinition[]): string {
|
||||||
|
const groups = new Map<TagType, TagDefinition[]>();
|
||||||
|
for (const tag of tags) {
|
||||||
|
const list = groups.get(tag.type) ?? [];
|
||||||
|
list.push(tag);
|
||||||
|
groups.set(tag.type, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: string[] = [];
|
||||||
|
for (const type of TYPE_ORDER) {
|
||||||
|
const group = groups.get(type);
|
||||||
|
if (!group || group.length === 0) continue;
|
||||||
|
|
||||||
|
const label = TYPE_LABELS[type];
|
||||||
|
const rows = group.map(t => {
|
||||||
|
const example = t.example ?? '';
|
||||||
|
return `| ${t.tag} | ${t.description} | ${example} |`;
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
`## ${label}\n\n` +
|
||||||
|
`| Tag | Description | Example |\n` +
|
||||||
|
`|-----|-------------|---------|\n` +
|
||||||
|
rows.join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `# Tag System Reference\n\n${sections.join('\n\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderNoteEffectRules(): string {
|
||||||
|
const tags = getTagDefinitions();
|
||||||
|
const skipByType = new Map<string, string[]>();
|
||||||
|
const createByType = new Map<string, string[]>();
|
||||||
|
|
||||||
|
for (const t of tags) {
|
||||||
|
const effect = t.noteEffect ?? 'none';
|
||||||
|
if (effect === 'none') continue;
|
||||||
|
const label = TYPE_LABELS[t.type] ?? t.type;
|
||||||
|
const map = effect === 'skip' ? skipByType : createByType;
|
||||||
|
const list = map.get(label) ?? [];
|
||||||
|
list.push(t.tag.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' '));
|
||||||
|
map.set(label, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatList = (map: Map<string, string[]>) =>
|
||||||
|
Array.from(map.entries()).map(([type, tags]) => `- **${type}:** ${tags.join(', ')}`).join('\n');
|
||||||
|
|
||||||
|
return [
|
||||||
|
`**SKIP if the email has ANY of these labels (skip labels override everything):**`,
|
||||||
|
formatList(skipByType),
|
||||||
|
``,
|
||||||
|
`**CREATE/UPDATE notes if the email has ANY of these labels (and no skip labels present):**`,
|
||||||
|
formatList(createByType),
|
||||||
|
``,
|
||||||
|
`**Logic:** If even one label falls in the "skip" list, skip the email — skip labels are hard filters that override create labels.`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTagSystemForNotes(): string {
|
||||||
|
const tags = getTagDefinitions().filter(t => t.applicability !== 'email');
|
||||||
|
return renderTagGroups(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTagSystemForEmails(): string {
|
||||||
|
const tags = getTagDefinitions().filter(t => t.applicability !== 'notes');
|
||||||
|
return renderTagGroups(tags);
|
||||||
|
}
|
||||||
243
apps/x/packages/core/src/knowledge/version_history.ts
Normal file
243
apps/x/packages/core/src/knowledge/version_history.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import git from 'isomorphic-git';
|
||||||
|
import { WorkDir } from '../config/config.js';
|
||||||
|
|
||||||
|
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||||
|
|
||||||
|
// Simple promise-based mutex to serialize commits
|
||||||
|
let commitLock: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
// Commit listeners for notifying other layers (e.g. renderer refresh)
|
||||||
|
type CommitListener = () => void;
|
||||||
|
const commitListeners: CommitListener[] = [];
|
||||||
|
|
||||||
|
export function onCommit(listener: CommitListener): () => void {
|
||||||
|
commitListeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
const idx = commitListeners.indexOf(listener);
|
||||||
|
if (idx >= 0) commitListeners.splice(idx, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a git repo in the knowledge directory if one doesn't exist.
|
||||||
|
* Stages all existing .md files and makes an initial commit.
|
||||||
|
*/
|
||||||
|
export async function initRepo(): Promise<void> {
|
||||||
|
const gitDir = path.join(KNOWLEDGE_DIR, '.git');
|
||||||
|
if (fs.existsSync(gitDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure knowledge dir exists
|
||||||
|
if (!fs.existsSync(KNOWLEDGE_DIR)) {
|
||||||
|
fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await git.init({ fs, dir: KNOWLEDGE_DIR });
|
||||||
|
|
||||||
|
// Stage all existing .md files
|
||||||
|
const files = getAllMdFiles(KNOWLEDGE_DIR, '');
|
||||||
|
for (const file of files) {
|
||||||
|
await git.add({ fs, dir: KNOWLEDGE_DIR, filepath: file });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
await git.commit({
|
||||||
|
fs,
|
||||||
|
dir: KNOWLEDGE_DIR,
|
||||||
|
message: 'Initial snapshot',
|
||||||
|
author: { name: 'Rowboat', email: 'local' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively find all .md files relative to the knowledge dir.
|
||||||
|
*/
|
||||||
|
function getAllMdFiles(baseDir: string, relDir: string): string[] {
|
||||||
|
const results: string[] = [];
|
||||||
|
const absDir = relDir ? path.join(baseDir, relDir) : baseDir;
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(absDir);
|
||||||
|
} catch {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry === '.git' || entry.startsWith('.')) continue;
|
||||||
|
const fullPath = path.join(absDir, entry);
|
||||||
|
const relPath = relDir ? `${relDir}/${entry}` : entry;
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
results.push(...getAllMdFiles(baseDir, relPath));
|
||||||
|
} else if (entry.endsWith('.md')) {
|
||||||
|
results.push(relPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage all changes to .md files and commit. No-op if nothing changed.
|
||||||
|
* Serialized via a promise lock to prevent concurrent git index corruption.
|
||||||
|
*/
|
||||||
|
export async function commitAll(message: string, authorName: string): Promise<void> {
|
||||||
|
const prev = commitLock;
|
||||||
|
let resolve: () => void;
|
||||||
|
commitLock = new Promise(r => { resolve = r; });
|
||||||
|
|
||||||
|
await prev;
|
||||||
|
try {
|
||||||
|
await commitAllInner(message, authorName);
|
||||||
|
} finally {
|
||||||
|
resolve!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitAllInner(message: string, authorName: string): Promise<void> {
|
||||||
|
const matrix = await git.statusMatrix({ fs, dir: KNOWLEDGE_DIR });
|
||||||
|
|
||||||
|
let hasChanges = false;
|
||||||
|
for (const [filepath, head, workdir, stage] of matrix) {
|
||||||
|
// Skip non-md files
|
||||||
|
if (!filepath.endsWith('.md')) continue;
|
||||||
|
|
||||||
|
// [filepath, HEAD, WORKDIR, STAGE]
|
||||||
|
// Unchanged: [f, 1, 1, 1]
|
||||||
|
if (head === 1 && workdir === 1 && stage === 1) continue;
|
||||||
|
|
||||||
|
hasChanges = true;
|
||||||
|
|
||||||
|
if (workdir === 0) {
|
||||||
|
// File deleted from workdir
|
||||||
|
await git.remove({ fs, dir: KNOWLEDGE_DIR, filepath });
|
||||||
|
} else {
|
||||||
|
// File added or modified
|
||||||
|
await git.add({ fs, dir: KNOWLEDGE_DIR, filepath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasChanges) return;
|
||||||
|
|
||||||
|
await git.commit({
|
||||||
|
fs,
|
||||||
|
dir: KNOWLEDGE_DIR,
|
||||||
|
message,
|
||||||
|
author: { name: authorName, email: 'local' },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const listener of commitListeners) {
|
||||||
|
try { listener(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitInfo {
|
||||||
|
oid: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FILE_HISTORY = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get commit history for a specific file.
|
||||||
|
* Returns commits where the file content changed, most recent first.
|
||||||
|
* Capped at MAX_FILE_HISTORY entries.
|
||||||
|
*/
|
||||||
|
export async function getFileHistory(knowledgeRelPath: string): Promise<CommitInfo[]> {
|
||||||
|
// Normalize path separators for git (always forward slashes)
|
||||||
|
const filepath = knowledgeRelPath.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
let commits: Awaited<ReturnType<typeof git.log>>;
|
||||||
|
try {
|
||||||
|
commits = await git.log({ fs, dir: KNOWLEDGE_DIR });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commits.length === 0) return [];
|
||||||
|
|
||||||
|
const result: CommitInfo[] = [];
|
||||||
|
|
||||||
|
// Walk through commits and check if file changed between consecutive commits
|
||||||
|
for (let i = 0; i < commits.length; i++) {
|
||||||
|
if (result.length >= MAX_FILE_HISTORY) break;
|
||||||
|
|
||||||
|
const commit = commits[i]!;
|
||||||
|
const parentCommit = commits[i + 1]; // undefined for the first (oldest) commit
|
||||||
|
|
||||||
|
const currentOid = await getBlobOidAtCommit(commit.oid, filepath);
|
||||||
|
const parentOid = parentCommit
|
||||||
|
? await getBlobOidAtCommit(parentCommit.oid, filepath)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Include this commit if:
|
||||||
|
// - The file existed and changed from parent
|
||||||
|
// - The file was added (parentOid is null but currentOid exists)
|
||||||
|
// - The file was deleted (currentOid is null but parentOid exists)
|
||||||
|
if (currentOid !== parentOid) {
|
||||||
|
result.push({
|
||||||
|
oid: commit.oid,
|
||||||
|
message: commit.commit.message.trim(),
|
||||||
|
timestamp: commit.commit.author.timestamp,
|
||||||
|
author: commit.commit.author.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the blob OID for a file at a specific commit, or null if not found.
|
||||||
|
*/
|
||||||
|
async function getBlobOidAtCommit(commitOid: string, filepath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const result = await git.readBlob({
|
||||||
|
fs,
|
||||||
|
dir: KNOWLEDGE_DIR,
|
||||||
|
oid: commitOid,
|
||||||
|
filepath,
|
||||||
|
});
|
||||||
|
// Compute a content hash from the blob to compare
|
||||||
|
return result.oid;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read file content at a specific commit.
|
||||||
|
*/
|
||||||
|
export async function getFileAtCommit(knowledgeRelPath: string, oid: string): Promise<string> {
|
||||||
|
const filepath = knowledgeRelPath.replace(/\\/g, '/');
|
||||||
|
const result = await git.readBlob({
|
||||||
|
fs,
|
||||||
|
dir: KNOWLEDGE_DIR,
|
||||||
|
oid,
|
||||||
|
filepath,
|
||||||
|
});
|
||||||
|
return Buffer.from(result.blob).toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a file to its content at a given commit, then commit the restoration.
|
||||||
|
*/
|
||||||
|
export async function restoreFile(knowledgeRelPath: string, oid: string): Promise<void> {
|
||||||
|
const content = await getFileAtCommit(knowledgeRelPath, oid);
|
||||||
|
const absPath = path.join(KNOWLEDGE_DIR, knowledgeRelPath);
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
const dir = path.dirname(absPath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(absPath, content, 'utf-8');
|
||||||
|
|
||||||
|
const filename = path.basename(knowledgeRelPath);
|
||||||
|
await commitAll(`Restored ${filename}`, 'You');
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,26 @@ export class FSModelConfigRepo implements IModelConfigRepo {
|
||||||
}
|
}
|
||||||
|
|
||||||
async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> {
|
async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> {
|
||||||
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
|
let existingProviders: Record<string, Record<string, unknown>> = {};
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.configPath, "utf8");
|
||||||
|
const existing = JSON.parse(raw);
|
||||||
|
existingProviders = existing.providers || {};
|
||||||
|
} catch {
|
||||||
|
// No existing config
|
||||||
|
}
|
||||||
|
|
||||||
|
existingProviders[config.provider.flavor] = {
|
||||||
|
...existingProviders[config.provider.flavor],
|
||||||
|
apiKey: config.provider.apiKey,
|
||||||
|
baseURL: config.provider.baseURL,
|
||||||
|
headers: config.provider.headers,
|
||||||
|
model: config.model,
|
||||||
|
models: config.models,
|
||||||
|
knowledgeGraphModel: config.knowledgeGraphModel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const toWrite = { ...config, providers: existingProviders };
|
||||||
|
await fs.writeFile(this.configPath, JSON.stringify(toWrite, null, 2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
const messageEvent = event as z.infer<typeof MessageEvent>;
|
const messageEvent = event as z.infer<typeof MessageEvent>;
|
||||||
if (messageEvent.message.role === 'user') {
|
if (messageEvent.message.role === 'user') {
|
||||||
const content = messageEvent.message.content;
|
const content = messageEvent.message.content;
|
||||||
if (typeof content === 'string' && content.trim()) {
|
let textContent: string | undefined;
|
||||||
// Clean attached-files XML and @mentions, then truncate to 100 chars
|
if (typeof content === 'string') {
|
||||||
const cleaned = cleanContentForTitle(content);
|
textContent = content;
|
||||||
if (!cleaned) continue; // Skip if only attached files/mentions
|
} else {
|
||||||
|
textContent = content
|
||||||
|
.filter(p => p.type === 'text')
|
||||||
|
.map(p => p.text)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
if (textContent && textContent.trim()) {
|
||||||
|
const cleaned = cleanContentForTitle(textContent);
|
||||||
|
if (!cleaned) continue;
|
||||||
return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
|
return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,9 +98,17 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
if (msg.role === 'user') {
|
if (msg.role === 'user') {
|
||||||
// Found first user message - use as title
|
// Found first user message - use as title
|
||||||
const content = msg.content;
|
const content = msg.content;
|
||||||
if (typeof content === 'string' && content.trim()) {
|
let textContent: string | undefined;
|
||||||
// Clean attached-files XML and @mentions, then truncate
|
if (typeof content === 'string') {
|
||||||
const cleaned = cleanContentForTitle(content);
|
textContent = content;
|
||||||
|
} else {
|
||||||
|
textContent = content
|
||||||
|
.filter(p => p.type === 'text')
|
||||||
|
.map(p => p.text)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
if (textContent && textContent.trim()) {
|
||||||
|
const cleaned = cleanContentForTitle(textContent);
|
||||||
if (cleaned) {
|
if (cleaned) {
|
||||||
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
|
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import container from "../di/container.js";
|
import container from "../di/container.js";
|
||||||
import { IMessageQueue } from "../application/lib/message-queue.js";
|
import { IMessageQueue, UserMessageContentType } from "../application/lib/message-queue.js";
|
||||||
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
||||||
import { IRunsRepo } from "./repo.js";
|
import { IRunsRepo } from "./repo.js";
|
||||||
import { IAgentRuntime } from "../agents/runtime.js";
|
import { IAgentRuntime } from "../agents/runtime.js";
|
||||||
|
|
@ -19,7 +19,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMessage(runId: string, message: string): Promise<string> {
|
export async function createMessage(runId: string, message: UserMessageContentType): Promise<string> {
|
||||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||||
const id = await queue.enqueue(runId, message);
|
const id = await queue.enqueue(runId, message);
|
||||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { z } from 'zod';
|
||||||
import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js';
|
import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js';
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js';
|
import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js';
|
||||||
|
import { commitAll } from '../knowledge/version_history.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Path Utilities
|
// Path Utilities
|
||||||
|
|
@ -218,6 +219,21 @@ export async function readFile(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounced commit for knowledge file edits
|
||||||
|
let knowledgeCommitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function scheduleKnowledgeCommit(filename: string): void {
|
||||||
|
if (knowledgeCommitTimer) {
|
||||||
|
clearTimeout(knowledgeCommitTimer);
|
||||||
|
}
|
||||||
|
knowledgeCommitTimer = setTimeout(() => {
|
||||||
|
knowledgeCommitTimer = null;
|
||||||
|
commitAll(`Edit ${filename}`, 'You').catch(err => {
|
||||||
|
console.error('[VersionHistory] Failed to commit after edit:', err);
|
||||||
|
});
|
||||||
|
}, 3 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
export async function writeFile(
|
export async function writeFile(
|
||||||
relPath: string,
|
relPath: string,
|
||||||
data: string,
|
data: string,
|
||||||
|
|
@ -266,6 +282,11 @@ export async function writeFile(
|
||||||
const stat = statToSchema(stats, 'file');
|
const stat = statToSchema(stats, 'file');
|
||||||
const etag = computeEtag(stats.size, stats.mtimeMs);
|
const etag = computeEtag(stats.size, stats.mtimeMs);
|
||||||
|
|
||||||
|
// Schedule a debounced version history commit for knowledge files
|
||||||
|
if (relPath.startsWith('knowledge/') && relPath.endsWith('.md')) {
|
||||||
|
scheduleKnowledgeCommit(path.basename(relPath));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: relPath,
|
path: relPath,
|
||||||
stat,
|
stat,
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,6 @@ export * as workspace from './workspace.js';
|
||||||
export * as mcp from './mcp.js';
|
export * as mcp from './mcp.js';
|
||||||
export * as agentSchedule from './agent-schedule.js';
|
export * as agentSchedule from './agent-schedule.js';
|
||||||
export * as agentScheduleState from './agent-schedule-state.js';
|
export * as agentScheduleState from './agent-schedule-state.js';
|
||||||
export * as serviceEvents from './service-events.js';
|
export * as serviceEvents from './service-events.js'
|
||||||
|
export * as inlineTask from './inline-task.js';
|
||||||
export { PrefixLogger };
|
export { PrefixLogger };
|
||||||
|
|
|
||||||
33
apps/x/packages/shared/src/inline-task.ts
Normal file
33
apps/x/packages/shared/src/inline-task.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const InlineTaskScheduleSchema = z.discriminatedUnion('type', [
|
||||||
|
z.object({
|
||||||
|
type: z.literal('cron'),
|
||||||
|
expression: z.string(),
|
||||||
|
startDate: z.string(),
|
||||||
|
endDate: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('window'),
|
||||||
|
cron: z.string(),
|
||||||
|
startTime: z.string(),
|
||||||
|
endTime: z.string(),
|
||||||
|
startDate: z.string(),
|
||||||
|
endDate: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('once'),
|
||||||
|
runAt: z.string(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type InlineTaskSchedule = z.infer<typeof InlineTaskScheduleSchema>;
|
||||||
|
|
||||||
|
export const InlineTaskBlockSchema = z.object({
|
||||||
|
instruction: z.string(),
|
||||||
|
schedule: InlineTaskScheduleSchema.optional(),
|
||||||
|
'schedule-label': z.string().optional(),
|
||||||
|
lastRunAt: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InlineTaskBlock = z.infer<typeof InlineTaskBlockSchema>;
|
||||||
|
|
@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
|
||||||
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||||||
import { AgentScheduleState } from './agent-schedule-state.js';
|
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||||
import { ServiceEvent } from './service-events.js';
|
import { ServiceEvent } from './service-events.js';
|
||||||
|
import { UserMessageContent } from './message.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Runtime Validation Schemas (Single Source of Truth)
|
// Runtime Validation Schemas (Single Source of Truth)
|
||||||
|
|
@ -128,7 +129,7 @@ const ipcSchemas = {
|
||||||
'runs:createMessage': {
|
'runs:createMessage': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
runId: z.string(),
|
runId: z.string(),
|
||||||
message: z.string(),
|
message: UserMessageContent,
|
||||||
}),
|
}),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
messageId: z.string(),
|
messageId: z.string(),
|
||||||
|
|
@ -419,6 +420,30 @@ const ipcSchemas = {
|
||||||
req: z.object({ path: z.string() }),
|
req: z.object({ path: z.string() }),
|
||||||
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
|
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
|
||||||
},
|
},
|
||||||
|
// Knowledge version history channels
|
||||||
|
'knowledge:history': {
|
||||||
|
req: z.object({ path: RelPath }),
|
||||||
|
res: z.object({
|
||||||
|
commits: z.array(z.object({
|
||||||
|
oid: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
timestamp: z.number(),
|
||||||
|
author: z.string(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'knowledge:fileAtCommit': {
|
||||||
|
req: z.object({ path: RelPath, oid: z.string() }),
|
||||||
|
res: z.object({ content: z.string() }),
|
||||||
|
},
|
||||||
|
'knowledge:restore': {
|
||||||
|
req: z.object({ path: RelPath, oid: z.string() }),
|
||||||
|
res: z.object({ ok: z.literal(true) }),
|
||||||
|
},
|
||||||
|
'knowledge:didCommit': {
|
||||||
|
req: z.object({}),
|
||||||
|
res: z.null(),
|
||||||
|
},
|
||||||
// Search channels
|
// Search channels
|
||||||
'search:query': {
|
'search:query': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
|
|
@ -435,6 +460,19 @@ const ipcSchemas = {
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
// Inline task schedule classification
|
||||||
|
'inline-task:classifySchedule': {
|
||||||
|
req: z.object({
|
||||||
|
instruction: z.string(),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
schedule: z.union([
|
||||||
|
z.object({ type: z.literal('cron'), expression: z.string(), startDate: z.string(), endDate: z.string(), label: z.string() }),
|
||||||
|
z.object({ type: z.literal('window'), cron: z.string(), startTime: z.string(), endTime: z.string(), startDate: z.string(), endDate: z.string(), label: z.string() }),
|
||||||
|
z.object({ type: z.literal('once'), runAt: z.string(), label: z.string() }),
|
||||||
|
]).nullable(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,30 @@ export const AssistantContentPart = z.union([
|
||||||
ToolCallPart,
|
ToolCallPart,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// A piece of user-typed text within a content array
|
||||||
|
export const UserTextPart = z.object({
|
||||||
|
type: z.literal("text"),
|
||||||
|
text: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// An attachment within a content array
|
||||||
|
export const UserAttachmentPart = z.object({
|
||||||
|
type: z.literal("attachment"),
|
||||||
|
path: z.string(), // absolute file path
|
||||||
|
filename: z.string(), // display name ("photo.png")
|
||||||
|
mimeType: z.string(), // MIME type ("image/png", "text/plain")
|
||||||
|
size: z.number().optional(), // bytes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Any single part of a user message (text or attachment)
|
||||||
|
export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]);
|
||||||
|
|
||||||
|
// Named type for user message content — used everywhere instead of repeating the union
|
||||||
|
export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]);
|
||||||
|
|
||||||
export const UserMessage = z.object({
|
export const UserMessage = z.object({
|
||||||
role: z.literal("user"),
|
role: z.literal("user"),
|
||||||
content: z.string(),
|
content: UserMessageContent,
|
||||||
providerOptions: ProviderOptions.optional(),
|
providerOptions: ProviderOptions.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,6 @@ export const LlmProvider = z.object({
|
||||||
export const LlmModelConfig = z.object({
|
export const LlmModelConfig = z.object({
|
||||||
provider: LlmProvider,
|
provider: LlmProvider,
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
|
models: z.array(z.string()).optional(),
|
||||||
|
knowledgeGraphModel: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ export const ServiceName = z.enum([
|
||||||
'fireflies',
|
'fireflies',
|
||||||
'granola',
|
'granola',
|
||||||
'voice_memo',
|
'voice_memo',
|
||||||
|
'email_labeling',
|
||||||
|
'note_tagging',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ServiceEventBase = z.object({
|
const ServiceEventBase = z.object({
|
||||||
|
|
|
||||||
216
apps/x/pnpm-lock.yaml
generated
216
apps/x/pnpm-lock.yaml
generated
|
|
@ -359,6 +359,9 @@ importers:
|
||||||
googleapis:
|
googleapis:
|
||||||
specifier: ^169.0.0
|
specifier: ^169.0.0
|
||||||
version: 169.0.0
|
version: 169.0.0
|
||||||
|
isomorphic-git:
|
||||||
|
specifier: ^1.29.0
|
||||||
|
version: 1.37.2
|
||||||
mammoth:
|
mammoth:
|
||||||
specifier: ^1.11.0
|
specifier: ^1.11.0
|
||||||
version: 1.11.0
|
version: 1.11.0
|
||||||
|
|
@ -3501,6 +3504,10 @@ packages:
|
||||||
abbrev@1.1.1:
|
abbrev@1.1.1:
|
||||||
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
||||||
|
|
||||||
|
abort-controller@3.0.0:
|
||||||
|
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||||
|
engines: {node: '>=6.5'}
|
||||||
|
|
||||||
abs-svg-path@0.1.1:
|
abs-svg-path@0.1.1:
|
||||||
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
|
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
|
||||||
|
|
||||||
|
|
@ -3627,6 +3634,9 @@ packages:
|
||||||
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
|
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
async-lock@1.4.1:
|
||||||
|
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
|
||||||
|
|
||||||
async@1.5.2:
|
async@1.5.2:
|
||||||
resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==}
|
resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==}
|
||||||
|
|
||||||
|
|
@ -3641,6 +3651,10 @@ packages:
|
||||||
resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==}
|
resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
available-typed-arrays@1.0.7:
|
||||||
|
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
awilix@12.0.5:
|
awilix@12.0.5:
|
||||||
resolution: {integrity: sha512-Qf/V/hRo6DK0FoBKJ9QiObasRxHAhcNi0mV6kW2JMawxS3zq6Un+VsZmVAZDUfvB+MjTEiJ2tUJUl4cr0JiUAw==}
|
resolution: {integrity: sha512-Qf/V/hRo6DK0FoBKJ9QiObasRxHAhcNi0mV6kW2JMawxS3zq6Un+VsZmVAZDUfvB+MjTEiJ2tUJUl4cr0JiUAw==}
|
||||||
engines: {node: '>=16.3.0'}
|
engines: {node: '>=16.3.0'}
|
||||||
|
|
@ -3742,6 +3756,9 @@ packages:
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||||
|
|
||||||
|
buffer@6.0.3:
|
||||||
|
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||||
|
|
||||||
bytes@3.1.2:
|
bytes@3.1.2:
|
||||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
@ -3762,6 +3779,10 @@ packages:
|
||||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
call-bind@1.0.8:
|
||||||
|
resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
call-bound@1.0.4:
|
call-bound@1.0.4:
|
||||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -3825,6 +3846,9 @@ packages:
|
||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
|
clean-git-ref@2.0.1:
|
||||||
|
resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==}
|
||||||
|
|
||||||
clean-stack@2.2.0:
|
clean-stack@2.2.0:
|
||||||
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
|
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -4256,6 +4280,9 @@ packages:
|
||||||
dfa@1.2.0:
|
dfa@1.2.0:
|
||||||
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
|
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
|
||||||
|
|
||||||
|
diff3@0.0.3:
|
||||||
|
resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==}
|
||||||
|
|
||||||
dingbat-to-unicode@1.0.1:
|
dingbat-to-unicode@1.0.1:
|
||||||
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
|
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
|
||||||
|
|
||||||
|
|
@ -4496,6 +4523,10 @@ packages:
|
||||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
event-target-shim@5.0.1:
|
||||||
|
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
eventemitter3@5.0.1:
|
eventemitter3@5.0.1:
|
||||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||||
|
|
||||||
|
|
@ -4640,6 +4671,10 @@ packages:
|
||||||
fontkit@2.0.4:
|
fontkit@2.0.4:
|
||||||
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
|
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
|
||||||
|
|
||||||
|
for-each@0.3.5:
|
||||||
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
foreground-child@3.3.1:
|
foreground-child@3.3.1:
|
||||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
@ -5104,6 +5139,10 @@ packages:
|
||||||
is-arrayish@0.3.4:
|
is-arrayish@0.3.4:
|
||||||
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
|
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
|
||||||
|
|
||||||
|
is-callable@1.2.7:
|
||||||
|
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
is-core-module@2.16.1:
|
is-core-module@2.16.1:
|
||||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -5170,6 +5209,10 @@ packages:
|
||||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
is-typed-array@1.1.15:
|
||||||
|
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
is-unicode-supported@0.1.0:
|
is-unicode-supported@0.1.0:
|
||||||
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -5184,6 +5227,9 @@ packages:
|
||||||
isarray@1.0.0:
|
isarray@1.0.0:
|
||||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
|
isarray@2.0.5:
|
||||||
|
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||||
|
|
||||||
isbinaryfile@4.0.10:
|
isbinaryfile@4.0.10:
|
||||||
resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
|
resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
|
||||||
engines: {node: '>= 8.0.0'}
|
engines: {node: '>= 8.0.0'}
|
||||||
|
|
@ -5191,6 +5237,11 @@ packages:
|
||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
isomorphic-git@1.37.2:
|
||||||
|
resolution: {integrity: sha512-HCQBBKmXIMPdHgYGstSBNp6MNmVcMQBbUqJF8xfywFmlpNseO4KKex59YlXqNxhRxmv3fUZwvNWvMyOdc1VvhA==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
jackspeak@3.4.3:
|
jackspeak@3.4.3:
|
||||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||||
|
|
||||||
|
|
@ -5762,6 +5813,9 @@ packages:
|
||||||
minimist@1.2.8:
|
minimist@1.2.8:
|
||||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||||
|
|
||||||
|
minimisted@2.0.1:
|
||||||
|
resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==}
|
||||||
|
|
||||||
minipass-collect@1.0.2:
|
minipass-collect@1.0.2:
|
||||||
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
|
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -6169,6 +6223,10 @@ packages:
|
||||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
pify@4.0.1:
|
||||||
|
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
pkce-challenge@5.0.1:
|
pkce-challenge@5.0.1:
|
||||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||||
engines: {node: '>=16.20.0'}
|
engines: {node: '>=16.20.0'}
|
||||||
|
|
@ -6186,6 +6244,10 @@ packages:
|
||||||
points-on-path@0.2.1:
|
points-on-path@0.2.1:
|
||||||
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
|
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
|
||||||
|
|
||||||
|
possible-typed-array-names@1.1.0:
|
||||||
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
postcss-value-parser@4.2.0:
|
postcss-value-parser@4.2.0:
|
||||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||||
|
|
||||||
|
|
@ -6220,6 +6282,10 @@ packages:
|
||||||
process-nextick-args@2.0.1:
|
process-nextick-args@2.0.1:
|
||||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
|
|
||||||
|
process@0.11.10:
|
||||||
|
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
progress@2.0.3:
|
progress@2.0.3:
|
||||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
@ -6434,6 +6500,10 @@ packages:
|
||||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
readable-stream@4.7.0:
|
||||||
|
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||||
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
|
|
||||||
readdirp@4.1.2:
|
readdirp@4.1.2:
|
||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||||
engines: {node: '>= 14.18.0'}
|
engines: {node: '>= 14.18.0'}
|
||||||
|
|
@ -6649,12 +6719,21 @@ packages:
|
||||||
server-destroy@1.0.1:
|
server-destroy@1.0.1:
|
||||||
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
|
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
|
||||||
|
|
||||||
|
set-function-length@1.2.2:
|
||||||
|
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
setimmediate@1.0.5:
|
setimmediate@1.0.5:
|
||||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||||
|
|
||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||||
|
|
||||||
|
sha.js@2.4.12:
|
||||||
|
resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==}
|
||||||
|
engines: {node: '>= 0.10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
shebang-command@1.2.0:
|
shebang-command@1.2.0:
|
||||||
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
|
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -6701,6 +6780,12 @@ packages:
|
||||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
simple-concat@1.0.1:
|
||||||
|
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||||
|
|
||||||
|
simple-get@4.0.1:
|
||||||
|
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||||
|
|
||||||
simple-swizzle@0.2.4:
|
simple-swizzle@0.2.4:
|
||||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||||
|
|
||||||
|
|
@ -6928,6 +7013,10 @@ packages:
|
||||||
resolution: {integrity: sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==}
|
resolution: {integrity: sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
to-buffer@1.2.2:
|
||||||
|
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
to-data-view@1.1.0:
|
to-data-view@1.1.0:
|
||||||
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
|
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
|
||||||
|
|
||||||
|
|
@ -6998,6 +7087,10 @@ packages:
|
||||||
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
typed-array-buffer@1.0.3:
|
||||||
|
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
typescript-eslint@8.50.1:
|
typescript-eslint@8.50.1:
|
||||||
resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==}
|
resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
@ -7272,6 +7365,10 @@ packages:
|
||||||
whatwg-url@5.0.0:
|
whatwg-url@5.0.0:
|
||||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||||
|
|
||||||
|
which-typed-array@1.1.20:
|
||||||
|
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
which@1.3.1:
|
which@1.3.1:
|
||||||
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
|
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
@ -11316,6 +11413,10 @@ snapshots:
|
||||||
|
|
||||||
abbrev@1.1.1: {}
|
abbrev@1.1.1: {}
|
||||||
|
|
||||||
|
abort-controller@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
event-target-shim: 5.0.1
|
||||||
|
|
||||||
abs-svg-path@0.1.1: {}
|
abs-svg-path@0.1.1: {}
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
|
|
@ -11440,6 +11541,8 @@ snapshots:
|
||||||
|
|
||||||
arrify@2.0.1: {}
|
arrify@2.0.1: {}
|
||||||
|
|
||||||
|
async-lock@1.4.1: {}
|
||||||
|
|
||||||
async@1.5.2:
|
async@1.5.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -11449,6 +11552,10 @@ snapshots:
|
||||||
|
|
||||||
author-regex@1.0.0: {}
|
author-regex@1.0.0: {}
|
||||||
|
|
||||||
|
available-typed-arrays@1.0.7:
|
||||||
|
dependencies:
|
||||||
|
possible-typed-array-names: 1.1.0
|
||||||
|
|
||||||
awilix@12.0.5:
|
awilix@12.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
camel-case: 4.1.2
|
camel-case: 4.1.2
|
||||||
|
|
@ -11568,6 +11675,11 @@ snapshots:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
|
|
||||||
|
buffer@6.0.3:
|
||||||
|
dependencies:
|
||||||
|
base64-js: 1.5.1
|
||||||
|
ieee754: 1.2.1
|
||||||
|
|
||||||
bytes@3.1.2: {}
|
bytes@3.1.2: {}
|
||||||
|
|
||||||
cacache@16.1.3:
|
cacache@16.1.3:
|
||||||
|
|
@ -11610,6 +11722,13 @@ snapshots:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
call-bind@1.0.8:
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
es-define-property: 1.0.1
|
||||||
|
get-intrinsic: 1.3.0
|
||||||
|
set-function-length: 1.2.2
|
||||||
|
|
||||||
call-bound@1.0.4:
|
call-bound@1.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
|
@ -11672,6 +11791,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|
||||||
|
clean-git-ref@2.0.1: {}
|
||||||
|
|
||||||
clean-stack@2.2.0: {}
|
clean-stack@2.2.0: {}
|
||||||
|
|
||||||
cli-cursor@3.1.0:
|
cli-cursor@3.1.0:
|
||||||
|
|
@ -12061,7 +12182,6 @@ snapshots:
|
||||||
es-define-property: 1.0.1
|
es-define-property: 1.0.1
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
optional: true
|
|
||||||
|
|
||||||
define-properties@1.2.1:
|
define-properties@1.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -12095,6 +12215,8 @@ snapshots:
|
||||||
|
|
||||||
dfa@1.2.0: {}
|
dfa@1.2.0: {}
|
||||||
|
|
||||||
|
diff3@0.0.3: {}
|
||||||
|
|
||||||
dingbat-to-unicode@1.0.1: {}
|
dingbat-to-unicode@1.0.1: {}
|
||||||
|
|
||||||
dir-compare@4.2.0:
|
dir-compare@4.2.0:
|
||||||
|
|
@ -12451,6 +12573,8 @@ snapshots:
|
||||||
|
|
||||||
etag@1.8.1: {}
|
etag@1.8.1: {}
|
||||||
|
|
||||||
|
event-target-shim@5.0.1: {}
|
||||||
|
|
||||||
eventemitter3@5.0.1: {}
|
eventemitter3@5.0.1: {}
|
||||||
|
|
||||||
events@3.3.0: {}
|
events@3.3.0: {}
|
||||||
|
|
@ -12638,6 +12762,10 @@ snapshots:
|
||||||
unicode-properties: 1.4.1
|
unicode-properties: 1.4.1
|
||||||
unicode-trie: 2.0.0
|
unicode-trie: 2.0.0
|
||||||
|
|
||||||
|
for-each@0.3.5:
|
||||||
|
dependencies:
|
||||||
|
is-callable: 1.2.7
|
||||||
|
|
||||||
foreground-child@3.3.1:
|
foreground-child@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
|
|
@ -12983,7 +13111,6 @@ snapshots:
|
||||||
has-property-descriptors@1.0.2:
|
has-property-descriptors@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-define-property: 1.0.1
|
es-define-property: 1.0.1
|
||||||
optional: true
|
|
||||||
|
|
||||||
has-symbols@1.1.0: {}
|
has-symbols@1.1.0: {}
|
||||||
|
|
||||||
|
|
@ -13251,6 +13378,8 @@ snapshots:
|
||||||
|
|
||||||
is-arrayish@0.3.4: {}
|
is-arrayish@0.3.4: {}
|
||||||
|
|
||||||
|
is-callable@1.2.7: {}
|
||||||
|
|
||||||
is-core-module@2.16.1:
|
is-core-module@2.16.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
|
|
@ -13300,6 +13429,10 @@ snapshots:
|
||||||
|
|
||||||
is-stream@2.0.1: {}
|
is-stream@2.0.1: {}
|
||||||
|
|
||||||
|
is-typed-array@1.1.15:
|
||||||
|
dependencies:
|
||||||
|
which-typed-array: 1.1.20
|
||||||
|
|
||||||
is-unicode-supported@0.1.0: {}
|
is-unicode-supported@0.1.0: {}
|
||||||
|
|
||||||
is-url@1.2.4: {}
|
is-url@1.2.4: {}
|
||||||
|
|
@ -13310,10 +13443,26 @@ snapshots:
|
||||||
|
|
||||||
isarray@1.0.0: {}
|
isarray@1.0.0: {}
|
||||||
|
|
||||||
|
isarray@2.0.5: {}
|
||||||
|
|
||||||
isbinaryfile@4.0.10: {}
|
isbinaryfile@4.0.10: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
isomorphic-git@1.37.2:
|
||||||
|
dependencies:
|
||||||
|
async-lock: 1.4.1
|
||||||
|
clean-git-ref: 2.0.1
|
||||||
|
crc-32: 1.2.2
|
||||||
|
diff3: 0.0.3
|
||||||
|
ignore: 5.3.2
|
||||||
|
minimisted: 2.0.1
|
||||||
|
pako: 1.0.11
|
||||||
|
pify: 4.0.1
|
||||||
|
readable-stream: 4.7.0
|
||||||
|
sha.js: 2.4.12
|
||||||
|
simple-get: 4.0.1
|
||||||
|
|
||||||
jackspeak@3.4.3:
|
jackspeak@3.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@isaacs/cliui': 8.0.2
|
'@isaacs/cliui': 8.0.2
|
||||||
|
|
@ -14139,6 +14288,10 @@ snapshots:
|
||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
|
minimisted@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
minimist: 1.2.8
|
||||||
|
|
||||||
minipass-collect@1.0.2:
|
minipass-collect@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
minipass: 3.3.6
|
minipass: 3.3.6
|
||||||
|
|
@ -14506,6 +14659,8 @@ snapshots:
|
||||||
|
|
||||||
pify@2.3.0: {}
|
pify@2.3.0: {}
|
||||||
|
|
||||||
|
pify@4.0.1: {}
|
||||||
|
|
||||||
pkce-challenge@5.0.1: {}
|
pkce-challenge@5.0.1: {}
|
||||||
|
|
||||||
pkg-types@1.3.1:
|
pkg-types@1.3.1:
|
||||||
|
|
@ -14527,6 +14682,8 @@ snapshots:
|
||||||
path-data-parser: 0.1.0
|
path-data-parser: 0.1.0
|
||||||
points-on-curve: 0.2.0
|
points-on-curve: 0.2.0
|
||||||
|
|
||||||
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss-value-parser@4.2.0: {}
|
postcss-value-parser@4.2.0: {}
|
||||||
|
|
||||||
postcss@8.5.6:
|
postcss@8.5.6:
|
||||||
|
|
@ -14565,6 +14722,8 @@ snapshots:
|
||||||
|
|
||||||
process-nextick-args@2.0.1: {}
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
|
process@0.11.10: {}
|
||||||
|
|
||||||
progress@2.0.3: {}
|
progress@2.0.3: {}
|
||||||
|
|
||||||
promise-inflight@1.0.1: {}
|
promise-inflight@1.0.1: {}
|
||||||
|
|
@ -14887,6 +15046,14 @@ snapshots:
|
||||||
string_decoder: 1.3.0
|
string_decoder: 1.3.0
|
||||||
util-deprecate: 1.0.2
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
|
readable-stream@4.7.0:
|
||||||
|
dependencies:
|
||||||
|
abort-controller: 3.0.0
|
||||||
|
buffer: 6.0.3
|
||||||
|
events: 3.3.0
|
||||||
|
process: 0.11.10
|
||||||
|
string_decoder: 1.3.0
|
||||||
|
|
||||||
readdirp@4.1.2: {}
|
readdirp@4.1.2: {}
|
||||||
|
|
||||||
rechoir@0.8.0:
|
rechoir@0.8.0:
|
||||||
|
|
@ -15175,10 +15342,25 @@ snapshots:
|
||||||
|
|
||||||
server-destroy@1.0.1: {}
|
server-destroy@1.0.1: {}
|
||||||
|
|
||||||
|
set-function-length@1.2.2:
|
||||||
|
dependencies:
|
||||||
|
define-data-property: 1.1.4
|
||||||
|
es-errors: 1.3.0
|
||||||
|
function-bind: 1.1.2
|
||||||
|
get-intrinsic: 1.3.0
|
||||||
|
gopd: 1.2.0
|
||||||
|
has-property-descriptors: 1.0.2
|
||||||
|
|
||||||
setimmediate@1.0.5: {}
|
setimmediate@1.0.5: {}
|
||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
|
sha.js@2.4.12:
|
||||||
|
dependencies:
|
||||||
|
inherits: 2.0.4
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
to-buffer: 1.2.2
|
||||||
|
|
||||||
shebang-command@1.2.0:
|
shebang-command@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 1.0.0
|
shebang-regex: 1.0.0
|
||||||
|
|
@ -15236,6 +15418,14 @@ snapshots:
|
||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
|
|
||||||
|
simple-concat@1.0.1: {}
|
||||||
|
|
||||||
|
simple-get@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
decompress-response: 6.0.0
|
||||||
|
once: 1.4.0
|
||||||
|
simple-concat: 1.0.1
|
||||||
|
|
||||||
simple-swizzle@0.2.4:
|
simple-swizzle@0.2.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-arrayish: 0.3.4
|
is-arrayish: 0.3.4
|
||||||
|
|
@ -15493,6 +15683,12 @@ snapshots:
|
||||||
unorm: 1.6.0
|
unorm: 1.6.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
to-buffer@1.2.2:
|
||||||
|
dependencies:
|
||||||
|
isarray: 2.0.5
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
typed-array-buffer: 1.0.3
|
||||||
|
|
||||||
to-data-view@1.1.0:
|
to-data-view@1.1.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -15550,6 +15746,12 @@ snapshots:
|
||||||
media-typer: 1.1.0
|
media-typer: 1.1.0
|
||||||
mime-types: 3.0.2
|
mime-types: 3.0.2
|
||||||
|
|
||||||
|
typed-array-buffer@1.0.3:
|
||||||
|
dependencies:
|
||||||
|
call-bound: 1.0.4
|
||||||
|
es-errors: 1.3.0
|
||||||
|
is-typed-array: 1.1.15
|
||||||
|
|
||||||
typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
|
|
@ -15827,6 +16029,16 @@ snapshots:
|
||||||
tr46: 0.0.3
|
tr46: 0.0.3
|
||||||
webidl-conversions: 3.0.1
|
webidl-conversions: 3.0.1
|
||||||
|
|
||||||
|
which-typed-array@1.1.20:
|
||||||
|
dependencies:
|
||||||
|
available-typed-arrays: 1.0.7
|
||||||
|
call-bind: 1.0.8
|
||||||
|
call-bound: 1.0.4
|
||||||
|
for-each: 0.3.5
|
||||||
|
get-proto: 1.0.1
|
||||||
|
gopd: 1.2.0
|
||||||
|
has-tostringtag: 1.0.2
|
||||||
|
|
||||||
which@1.3.1:
|
which@1.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue