Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion apps/web/src/ai/agents/journl-agent-context.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
type JournalEntryEditor = {
date: string;
type: "journal-entry";
};

type PageEditor = {
id: string;
title: string;
type: "page";
};

/**
* The context of the Journl agent.
*/
export type JournlAgentContext = {
activeEditors: string[];
activeEditors: (JournalEntryEditor | PageEditor)[];
currentDate: string;
highlightedText: string[];
user: {
Expand Down
111 changes: 61 additions & 50 deletions apps/web/src/ai/agents/journl-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Agent } from "@mastra/core/agent";
import { RuntimeContext } from "@mastra/core/runtime-context";
import { z } from "zod/v4";
import { model } from "~/ai/providers/openai/text";
import { env } from "~/env";
import { createPage } from "../tools/create-page";
import { manipulateEditor } from "../tools/manipulate-editor";
import { navigateJournalEntry } from "../tools/navigate-journal-entry";
import { navigatePage } from "../tools/navigate-page";
Expand All @@ -16,17 +18,53 @@ export const journlAgent = new Agent({
description: `${AGENT_NAME}, an AI companion for personal reflection, journaling, and knowledge discovery.`,
instructions: ({ runtimeContext }) => {
const context = getJournlRuntimeContext(runtimeContext);
if (env.NODE_ENV === "development") {
console.debug("context 👀", context);
}
return `You are ${AGENT_NAME}, an AI companion that helps users write, navigate, and manage their own notes.

Current date: ${context.currentDate}
User's name: ${context.user.name}

Do not reproduce song lyrics or any other copyrighted material, even if asked.

# User State (deterministic, read-only)

${
context.view.name === "journal-timeline"
? `- Currently at the journal timeline ${context.view.focusedDate ? `and engaged with the entry of the date ${context.view.focusedDate}` : ""}.`
: context.view.name === "journal-entry"
? `- Currently at the journal entry of date ${context.view.date}.`
: context.view.name === "page"
? `- Currently at the page of UUID ${context.view.id} with the title ${context.view.title}.`
: "- Currently at a different view without editors."
}
${
context.activeEditors.length > 0
? `- ${context.activeEditors.length > 1 ? `There are ${context.activeEditors.length} active editors` : "There is one active editor"}: ${context.activeEditors.map((editor) => JSON.stringify(editor)).join(", ")}`
: ""
}
${
context.highlightedText.length > 0
? `- User has highlighted text: ${context.highlightedText.join(", ")}.`
: ""
}

# Tools

### \`manipulateEditor\`

Modify the active editor (insert/append/prepend/replace text; headings, bullets, and so on). When you use the \`manipulateEditor\` tool, it immediately modifies the target editor in the UI. There is no background work; changes apply now.
Modify the content of the target editor (insert/append/prepend/replace text; headings, bullets, and so on).

**Important**: The target editor has to be the ID of one of the active editors, if you don't know which to use, do not call this tool and ask the user to clarify instead.

- The generated \`userPrompt\` for the \`manipulateEditor\` tool MUST include as much detail as possible.
- The prompt will be used by a different agent that will be manipulating the editor client-side, and should be treated as such.
- Any content you generate should be markdown that is immediately usable. Avoid placeholder text.
- Do not add titles to the pages because they are handled separately from the editor.
- **No fabrication**. Never invent prior notes, pages, links, or other content.

After a successful call to the \`manipulateEditor\` tool, avoid telling the user that the changes were made. At most, summarize the changes in a few words.

Use when:
- The user wants to write/add/insert/capture/log/note content.
Expand All @@ -36,13 +74,6 @@ Use when:
Do not use when:
- The user only wants recall/analysis of prior content (use search tools instead).

The generated \`userPrompt\` for the \`manipulateEditor\` tool MUST include as much detail as possible:

- **Content** — markdown that is immediately usable (headings, bullets/numbered lists, block quotes \`>\`, code fences). Avoid placeholder text.
- **Voice** — preserve the user's phrasing for reflections; tighten only for structure
- **No fabrication** — never invent prior notes or links
- When producing checklists: 1) use \`- [ ]\` / \`- [x]\`; 2) one task per line; 3) keep tasks short and actionable.

### \`semanticJournalSearch\`

Semantic search over journal entries (daily notes). The user says or implies "find when I talked about X / patterns in Y / times I felt Z" or requests to search for a specific topic/theme/emotion.
Expand All @@ -69,57 +100,25 @@ Open a specific page by **UUID only**. The user says or implies "open/go to <pag

If you don't know the UUID of the page, use the \`semanticPageSearch\` tool to find it before using this tool.

# Examples

- “write/add/insert/capture/log/note”: \`manipulateEditor\`
- “format/make a checklist/quote/code/heading/tag” \`manipulateEditor\`
- “open/go to today/yesterday/2025-06-02/last Monday”: \`navigateJournalEntry\`
- “open/go to <page title or UUID>”: \`navigatePage\`
- “find when I talked about X / patterns in Y / times I felt Z”: \`semanticJournalSearch\` (optionally bound by \`temporalJournalSearch\`)
- “pull my notes on <topic> across pages; summarize/synthesize”: \`semanticPageSearch\`
- “show me last week/month/quarter entries about <theme>”: \`temporalJournalSearch\` (+ semantic re-ranking if useful)
### \`createPage\`

---

# Global Behavior Meta
Create a new page with the given title, infer the title from the user's prompt and clarify if it's not clear. Use when the user says or implies "create/new/add a page".

- **Important**: If the user is referring to one of the current editors (for example: "today's note", "the page", or similar), FIRST read and search the content of the active editor(s) and answer using those contents. Do not ask the user for anything that you can already access. If no active editor is available, say so and ask which document to use.
- No background or delayed work. Complete tasks in this response.
- Interpret relative time against Current date: ${context.currentDate}.
- Prefer partial completion over clarifying questions when scope is large.
- Mirror the user's tone (e.g., casual or analytical), but avoid corporate filler. Default to casual.
- Quote the user's exact words when it adds clarity or validation. Avoid over-quoting. Be concise and high-signal.
- If the next step is obvious, do it. Example of bad: "bad example: "If you want to see the key insights, I can show them to you.", example of good: "Here are the key insights I found".
- Operate on what exists in Journl and what the user says; never fabricate content. Prefer direct tool actions over prose when the intent is to write, insert, or navigate.
Do not navigate to the page after creating it, it will be done automatically.

---

# User UI State (deterministic, read-only)
# Global Behavior Meta

- Call user by their name: ${context.user.name}.
${
context.view.name === "journal-timeline" && context.view.focusedDate
? `- The user is currently focused on the journal timeline and is engaged with the entry of the date ${context.view.focusedDate}.`
: context.view.name === "journal-entry"
? `- The user is currently focused on the journal entry of the date ${context.view.date}.`
: context.view.name === "page"
? `- The user is currently focused on the page of the UUID ${context.view.id} with the title ${context.view.title}.`
: "- The user is currently on a page without editors."
}
${
context.activeEditors.length > 0
? `- Active editor${context.activeEditors.length > 1 ? "s" : ""}: ${context.activeEditors.join(", ")}.`
: ""
}
${
context.highlightedText.length > 0
? `- User has highlighted: ${context.highlightedText.join(", ")}.`
: ""
}`;
- **Important**: If user refers to current editors ("today's note", "the page"), simply read the content of the active editor(s) for context. Don't ask for information you can already access.
- Complete tasks immediately. Take obvious next steps. Prefer direct tool actions over explanatory prose.
- Mirror user's tone but avoid corporate filler. Be concise and high-signal.
- Operate only on existing content; never fabricate. Prefer partial completion over clarifying questions when scope is large.`;
},
model,
name: AGENT_NAME,
tools: {
createPage,
manipulateEditor,
navigateJournalEntry,
navigatePage,
Expand All @@ -130,7 +129,19 @@ ${
});

const zJournlRuntimeContext: z.ZodType<JournlAgentContext> = z.object({
activeEditors: z.array(z.string()),
activeEditors: z.array(
z.union([
z.object({
date: z.string(),
type: z.literal("journal-entry"),
}),
z.object({
id: z.string(),
title: z.string(),
type: z.literal("page"),
}),
]),
),
currentDate: z.string(),
highlightedText: z.array(z.string()),
user: z.object({
Expand Down
21 changes: 16 additions & 5 deletions apps/web/src/ai/agents/use-journl-agent-awareness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,21 @@ export type BlockSelection = {
text: string;
};

type JournlEditor = JournlAgentContext["activeEditors"][number] & {
editor: EditorPrimitive;
};

const JournlAgentAwarenessContext = createContext<{
forgetEditor: (id: string) => void;
forgetEditorSelections: (editor: EditorPrimitive) => void;
forgetSelection: (selection: BlockSelection) => void;
getEditors: () => Map<string, EditorPrimitive>;
getEditors: () => Map<string, JournlEditor>;
getSelection: (
selection: Pick<BlockSelection, "editor" | "blockIds">,
) => BlockSelection | undefined;
getSelections: () => BlockSelection[];
getView: () => JournlAgentContext["view"];
rememberEditor: (id: string, editor: EditorPrimitive) => void;
rememberEditor: (activeEditor: JournlEditor) => void;
rememberSelection: (selection: BlockSelection) => void;
rememberView: (view: JournlAgentContext["view"]) => void;
} | null>(null);
Expand All @@ -47,7 +51,7 @@ export function JournlAgentAwarenessProvider({
const [_, setSelectedBlocks] = useState<BlockSelection[]>([]);
const ref = useRef<{
view: JournlAgentContext["view"];
editors: Map<string, EditorPrimitive>;
editors: Map<string, JournlEditor>;
selectedBlocks: BlockSelection[];
}>({
editors: new Map(),
Expand Down Expand Up @@ -100,8 +104,15 @@ export function JournlAgentAwarenessProvider({
return ref.current.view;
}, []);

const rememberEditor = useCallback((id: string, editor: EditorPrimitive) => {
ref.current.editors.set(id, editor);
const rememberEditor = useCallback((activeEditor: JournlEditor) => {
const id =
activeEditor.type === "journal-entry"
? activeEditor.date
: activeEditor.id;
ref.current.editors.set(id, {
...activeEditor,
editor: activeEditor.editor,
});
}, []);

const rememberSelection = useCallback((selection: BlockSelection) => {
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/ai/agents/use-journl-agent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type UIMessage,
} from "ai";
import { env } from "~/env";
import { useCreatePageTool } from "../tools/create-page.client";
import { useManipulateEditorTool } from "../tools/manipulate-editor.client";
import { useNavigateJournalEntryTool } from "../tools/navigate-journal-entry.client";
import { useNavigatePageTool } from "../tools/navigate-page.client";
Expand All @@ -25,11 +26,13 @@ type UseJournlAgentOptions<Message extends UIMessage = UIMessage> = {} & Pick<

export function useJournlAgent({ transport, messages }: UseJournlAgentOptions) {
const { getEditors, getView, getSelections } = useJournlAgentAwareness();
const createPage = useCreatePageTool();
const navigateJournalEntry = useNavigateJournalEntryTool();
const navigatePage = useNavigatePageTool();
const manipulateEditor = useManipulateEditorTool();

const tools = new Map<string, ClientTool<string, UseChatHelpers<UIMessage>>>([
[createPage.name, createPage],
[navigateJournalEntry.name, navigateJournalEntry],
[navigatePage.name, navigatePage],
[manipulateEditor.name, manipulateEditor],
Expand All @@ -55,13 +58,14 @@ export function useJournlAgent({ transport, messages }: UseJournlAgentOptions) {
transport: new DefaultChatTransport({
...transport,
prepareSendMessagesRequest: ({ id, messages, trigger, messageId }) => {
const activeEditors = Array.from(getEditors().keys());
const selections = getSelections();
const view = getView();
return {
body: {
context: {
activeEditors,
activeEditors: Array.from(getEditors().entries()).map(
([_, { editor, ...rest }]) => rest,
),
currentDate: new Date().toLocaleString(),
highlightedText: selections.map((selection) => selection.text),
view,
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/ai/tools/create-page.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import type { Page } from "@acme/db/schema";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useTRPC } from "~/trpc/react";
import { useAppEventEmitter } from "../../components/events/app-event-context";
import { PageCreatedEvent } from "../../events/page-created-event";
import { createClientTool } from "../utils/create-client-tool";
import { zCreatePageInput } from "./create-page.schema";

export function useCreatePageTool() {
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
const eventEmitter = useAppEventEmitter();

const { mutate: createPage } = useMutation(
trpc.pages.create.mutationOptions({}),
);

const tool = createClientTool({
execute: async (toolCall, chat) => {
createPage(
{
children: [],
title: toolCall.input.title,
},
{
onError: (error) => {
console.error("Failed to create page:", error);
void chat.addToolResult({
output: `Failed to create page ${toolCall.input.title}`,
tool: toolCall.toolName,
toolCallId: toolCall.toolCallId,
});
},
onSuccess: (newPage) => {
queryClient.setQueryData(
trpc.pages.getByUser.queryOptions().queryKey,
(oldPages: Page[] | undefined) => {
if (!oldPages) return [newPage];
return [newPage, ...oldPages];
},
);

eventEmitter.buffer(
new PageCreatedEvent({
chat,
id: newPage.id,
title: toolCall.input.title,
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
}),
);

router.push(`/pages/${newPage.id}`);
},
},
);
},
inputSchema: zCreatePageInput,
name: "createPage",
});
return tool;
}
5 changes: 5 additions & 0 deletions apps/web/src/ai/tools/create-page.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { z } from "zod";

export const zCreatePageInput = z.object({
title: z.string().describe("The title of the page to create."),
});
10 changes: 10 additions & 0 deletions apps/web/src/ai/tools/create-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { zCreatePageInput } from "./create-page.schema";

export const createPage = createTool({
description: "A client-side tool that creates a new page",
id: "create-page",
inputSchema: zCreatePageInput,
outputSchema: z.void(),
});
13 changes: 8 additions & 5 deletions apps/web/src/ai/tools/manipulate-editor.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,20 @@ export function useManipulateEditorTool() {
const tool = createClientTool({
execute: async (toolCall, chat) => {
try {
const editor = getEditors().get(toolCall.input.editorId);
const editor = getEditors().get(toolCall.input.targetEditor)?.editor;

if (!editor) {
const availableEditors = JSON.stringify(
Object.fromEntries(getEditors().entries()),
const activeEditors = JSON.stringify(
Array.from(getEditors().values()).map(
({ editor, ...rest }) => rest,
),
);
return await chat.addToolResult({
output: `Editor ${toolCall.input.editorId} was not found. Please call the tool again with the available editors: ${availableEditors}`,
void chat.addToolResult({
output: `Editor ${toolCall.input.targetEditor} was not found. Please call the tool again targeting one of the following editors: ${activeEditors}`,
tool: toolCall.toolName,
toolCallId: toolCall.toolCallId,
});
return;
}

const aiExtension = getAIExtension(editor);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/ai/tools/manipulate-editor.schema.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { z } from "zod";

export const zManipulateEditorInput = z.object({
editorId: z
targetEditor: z
.string()
.describe(
"The ID of one of the active editors to manipulate. It is the date of a journal entry (in YYYY-MM-DD format) or a page ID (UUID).",
"The target editor to manipulate. It is the date of a journal entry (in YYYY-MM-DD format) or a page ID (UUID format).",
),
userPrompt: z
.string()
Expand Down
Loading
Loading