mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge branch 'dev' into notification2
This commit is contained in:
commit
bc8c288e1b
55 changed files with 1282 additions and 138 deletions
|
|
@ -37,6 +37,7 @@
|
|||
"openid-client": "^6.8.1",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"posthog-node": "^4.18.0",
|
||||
"react": "^19.2.3",
|
||||
"xlsx": "^0.18.5",
|
||||
"yaml": "^2.8.2",
|
||||
|
|
|
|||
|
|
@ -164,7 +164,11 @@ async function runAgent(
|
|||
|
||||
try {
|
||||
// Create a new run via core (resolves agent + default model+provider).
|
||||
const run = await createRun({ agentId: agentName });
|
||||
const run = await createRun({
|
||||
agentId: agentName,
|
||||
useCase: 'copilot_chat',
|
||||
subUseCase: 'scheduled',
|
||||
});
|
||||
console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`);
|
||||
|
||||
// Add the starting message as a user message
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import { IRunsLock } from "../runs/lock.js";
|
|||
import { IAbortRegistry } from "../runs/abort-registry.js";
|
||||
import { PrefixLogger } from "@x/shared";
|
||||
import { parse } from "yaml";
|
||||
import { captureLlmUsage } from "../analytics/usage.js";
|
||||
import { enterUseCase, type UseCase } from "../analytics/use_case.js";
|
||||
import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js";
|
||||
import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
||||
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
|
||||
|
|
@ -650,6 +652,8 @@ export class AgentState {
|
|||
agentName: string | null = null;
|
||||
runModel: string | null = null;
|
||||
runProvider: string | null = null;
|
||||
runUseCase: UseCase | null = null;
|
||||
runSubUseCase: string | null = null;
|
||||
messages: z.infer<typeof MessageList> = [];
|
||||
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
|
||||
subflowStates: Record<string, AgentState> = {};
|
||||
|
|
@ -765,6 +769,8 @@ export class AgentState {
|
|||
this.agentName = event.agentName;
|
||||
this.runModel = event.model;
|
||||
this.runProvider = event.provider;
|
||||
this.runUseCase = event.useCase ?? null;
|
||||
this.runSubUseCase = event.subUseCase ?? null;
|
||||
break;
|
||||
case "spawn-subflow":
|
||||
// Seed the subflow state with its agent so downstream loadAgent works.
|
||||
|
|
@ -775,6 +781,8 @@ export class AgentState {
|
|||
this.subflowStates[event.toolCallId].agentName = event.agentName;
|
||||
this.subflowStates[event.toolCallId].runModel = this.runModel;
|
||||
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
|
||||
this.subflowStates[event.toolCallId].runUseCase = this.runUseCase;
|
||||
this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase;
|
||||
break;
|
||||
case "message":
|
||||
this.messages.push(event.message);
|
||||
|
|
@ -881,6 +889,14 @@ export async function* streamAgent({
|
|||
const model = provider.languageModel(modelId);
|
||||
logger.log(`using model: ${modelId} (provider: ${state.runProvider})`);
|
||||
|
||||
// Install use-case context for tool-internal LLM calls (e.g. parseFile)
|
||||
// so they can tag their `llm_usage` events with the parent run's category.
|
||||
enterUseCase({
|
||||
useCase: state.runUseCase ?? "copilot_chat",
|
||||
...(state.runSubUseCase ? { subUseCase: state.runSubUseCase } : {}),
|
||||
...(state.agentName ? { agentName: state.agentName } : {}),
|
||||
});
|
||||
|
||||
let loopCounter = 0;
|
||||
let voiceInput = false;
|
||||
let voiceOutput: 'summary' | 'full' | null = null;
|
||||
|
|
@ -1114,6 +1130,13 @@ export async function* streamAgent({
|
|||
instructionsWithDateTime,
|
||||
tools,
|
||||
signal,
|
||||
{
|
||||
useCase: state.runUseCase ?? "copilot_chat",
|
||||
...(state.runSubUseCase ? { subUseCase: state.runSubUseCase } : {}),
|
||||
agentName: state.agentName ?? undefined,
|
||||
modelId,
|
||||
providerName: state.runProvider!,
|
||||
},
|
||||
)) {
|
||||
messageBuilder.ingest(event);
|
||||
yield* processEvent({
|
||||
|
|
@ -1201,12 +1224,21 @@ export async function* streamAgent({
|
|||
}
|
||||
}
|
||||
|
||||
interface StreamLlmAnalytics {
|
||||
useCase: UseCase;
|
||||
subUseCase?: string;
|
||||
agentName?: string;
|
||||
modelId: string;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
async function* streamLlm(
|
||||
model: LanguageModel,
|
||||
messages: z.infer<typeof MessageList>,
|
||||
instructions: string,
|
||||
tools: ToolSet,
|
||||
signal?: AbortSignal,
|
||||
analytics?: StreamLlmAnalytics,
|
||||
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
|
||||
const converted = convertFromMessages(messages);
|
||||
console.log(`! SENDING payload to model: `, JSON.stringify(converted))
|
||||
|
|
@ -1277,6 +1309,16 @@ async function* streamLlm(
|
|||
};
|
||||
break;
|
||||
case "finish-step":
|
||||
if (analytics) {
|
||||
captureLlmUsage({
|
||||
useCase: analytics.useCase,
|
||||
...(analytics.subUseCase ? { subUseCase: analytics.subUseCase } : {}),
|
||||
...(analytics.agentName ? { agentName: analytics.agentName } : {}),
|
||||
model: analytics.modelId,
|
||||
provider: analytics.providerName,
|
||||
usage: event.usage,
|
||||
});
|
||||
}
|
||||
yield {
|
||||
type: "finish-step",
|
||||
usage: event.usage,
|
||||
|
|
|
|||
23
apps/x/packages/core/src/analytics/identify.ts
Normal file
23
apps/x/packages/core/src/analytics/identify.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { isSignedIn } from '../account/account.js';
|
||||
import { getBillingInfo } from '../billing/billing.js';
|
||||
import { identify } from './posthog.js';
|
||||
|
||||
/**
|
||||
* If the user has rowboat OAuth tokens, fetch their billing info and
|
||||
* call posthog.identify(). Idempotent — safe to call on every app start.
|
||||
* Catches all errors so analytics never blocks app launch.
|
||||
*/
|
||||
export async function identifyIfSignedIn(): Promise<void> {
|
||||
try {
|
||||
if (!(await isSignedIn())) return;
|
||||
const billing = await getBillingInfo();
|
||||
if (!billing.userId) return;
|
||||
identify(billing.userId, {
|
||||
...(billing.userEmail ? { email: billing.userEmail } : {}),
|
||||
plan: billing.subscriptionPlan,
|
||||
status: billing.subscriptionStatus,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Analytics] startup identify failed:', err);
|
||||
}
|
||||
}
|
||||
37
apps/x/packages/core/src/analytics/installation.ts
Normal file
37
apps/x/packages/core/src/analytics/installation.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
|
||||
const INSTALLATION_PATH = path.join(WorkDir, 'config', 'installation.json');
|
||||
|
||||
let cached: string | null = null;
|
||||
|
||||
export function getInstallationId(): string {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
if (fs.existsSync(INSTALLATION_PATH)) {
|
||||
const raw = fs.readFileSync(INSTALLATION_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { installationId?: string };
|
||||
if (parsed.installationId && typeof parsed.installationId === 'string') {
|
||||
cached = parsed.installationId;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to read installation.json:', err);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
try {
|
||||
const dir = path.dirname(INSTALLATION_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(INSTALLATION_PATH, JSON.stringify({ installationId: id }, null, 2));
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to write installation.json:', err);
|
||||
}
|
||||
cached = id;
|
||||
return id;
|
||||
}
|
||||
90
apps/x/packages/core/src/analytics/posthog.ts
Normal file
90
apps/x/packages/core/src/analytics/posthog.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { PostHog } from 'posthog-node';
|
||||
import { getInstallationId } from './installation.js';
|
||||
import { API_URL } from '../config/env.js';
|
||||
|
||||
// Build-time injected via esbuild `define` (apps/main/bundle.mjs).
|
||||
// In dev/tsc, fall back to process.env so local runs work too.
|
||||
const POSTHOG_KEY = process.env.POSTHOG_KEY ?? process.env.VITE_PUBLIC_POSTHOG_KEY ?? '';
|
||||
const POSTHOG_HOST = process.env.POSTHOG_HOST ?? process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com';
|
||||
|
||||
let client: PostHog | null = null;
|
||||
let initAttempted = false;
|
||||
let identifiedUserId: string | null = null;
|
||||
|
||||
function getClient(): PostHog | null {
|
||||
if (initAttempted) return client;
|
||||
initAttempted = true;
|
||||
if (!POSTHOG_KEY) {
|
||||
console.log('[Analytics] POSTHOG_KEY not set; analytics disabled');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
client = new PostHog(POSTHOG_KEY, {
|
||||
host: POSTHOG_HOST,
|
||||
flushAt: 20,
|
||||
flushInterval: 10_000,
|
||||
});
|
||||
// Tag the install with api_url as a person property up-front,
|
||||
// so anonymous users are also segmentable by environment (api_url
|
||||
// distinguishes prod / staging / custom — meaning is assigned in PostHog).
|
||||
client.identify({
|
||||
distinctId: getInstallationId(),
|
||||
properties: { api_url: API_URL },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to init PostHog:', err);
|
||||
client = null;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function activeDistinctId(): string {
|
||||
return identifiedUserId ?? getInstallationId();
|
||||
}
|
||||
|
||||
export function capture(event: string, properties?: Record<string, unknown>): void {
|
||||
const ph = getClient();
|
||||
if (!ph) return;
|
||||
try {
|
||||
ph.capture({
|
||||
distinctId: activeDistinctId(),
|
||||
event,
|
||||
properties,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Analytics] capture failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function identify(userId: string, properties?: Record<string, unknown>): void {
|
||||
const ph = getClient();
|
||||
if (!ph) return;
|
||||
try {
|
||||
// Alias the anonymous installation ID to the rowboat user ID so historical
|
||||
// anonymous events are linked to the identified user.
|
||||
ph.alias({ distinctId: userId, alias: getInstallationId() });
|
||||
ph.identify({
|
||||
distinctId: userId,
|
||||
properties: {
|
||||
...properties,
|
||||
api_url: API_URL,
|
||||
},
|
||||
});
|
||||
identifiedUserId = userId;
|
||||
} catch (err) {
|
||||
console.error('[Analytics] identify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function reset(): void {
|
||||
identifiedUserId = null;
|
||||
}
|
||||
|
||||
export async function shutdown(): Promise<void> {
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.shutdown();
|
||||
} catch (err) {
|
||||
console.error('[Analytics] shutdown failed:', err);
|
||||
}
|
||||
}
|
||||
38
apps/x/packages/core/src/analytics/usage.ts
Normal file
38
apps/x/packages/core/src/analytics/usage.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { capture } from './posthog.js';
|
||||
import type { UseCase } from './use_case.js';
|
||||
|
||||
// Shape compatible with ai-sdk v5 `LanguageModelUsage`.
|
||||
// All fields are optional because providers report subsets.
|
||||
export interface LlmUsageInput {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
}
|
||||
|
||||
export interface CaptureLlmUsageArgs {
|
||||
useCase: UseCase;
|
||||
subUseCase?: string;
|
||||
agentName?: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
usage: LlmUsageInput | undefined;
|
||||
}
|
||||
|
||||
export function captureLlmUsage(args: CaptureLlmUsageArgs): void {
|
||||
const usage = args.usage ?? {};
|
||||
const properties: Record<string, unknown> = {
|
||||
use_case: args.useCase,
|
||||
model: args.model,
|
||||
provider: args.provider,
|
||||
input_tokens: usage.inputTokens ?? 0,
|
||||
output_tokens: usage.outputTokens ?? 0,
|
||||
total_tokens: usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
||||
};
|
||||
if (args.subUseCase) properties.sub_use_case = args.subUseCase;
|
||||
if (args.agentName) properties.agent_name = args.agentName;
|
||||
if (usage.cachedInputTokens != null) properties.cached_input_tokens = usage.cachedInputTokens;
|
||||
if (usage.reasoningTokens != null) properties.reasoning_tokens = usage.reasoningTokens;
|
||||
capture('llm_usage', properties);
|
||||
}
|
||||
28
apps/x/packages/core/src/analytics/use_case.ts
Normal file
28
apps/x/packages/core/src/analytics/use_case.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
export type UseCase = 'copilot_chat' | 'track_block' | 'meeting_note' | 'knowledge_sync';
|
||||
|
||||
export interface UseCaseContext {
|
||||
useCase: UseCase;
|
||||
subUseCase?: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
const storage = new AsyncLocalStorage<UseCaseContext>();
|
||||
|
||||
export function withUseCase<T>(ctx: UseCaseContext, fn: () => T): T {
|
||||
return storage.run(ctx, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently install a use-case context for the current async chain.
|
||||
* Use inside generator functions where wrapping with `withUseCase()` doesn't
|
||||
* compose. Child async work (e.g. tool execution) will inherit it.
|
||||
*/
|
||||
export function enterUseCase(ctx: UseCaseContext): void {
|
||||
storage.enterWith(ctx);
|
||||
}
|
||||
|
||||
export function getCurrentUseCase(): UseCaseContext | undefined {
|
||||
return storage.getStore();
|
||||
}
|
||||
|
|
@ -87,6 +87,23 @@ ${schemaYaml}
|
|||
|
||||
**Runtime-managed fields — never write these yourself:** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `.
|
||||
|
||||
## Do Not Set ` + "`" + `model` + "`" + ` or ` + "`" + `provider` + "`" + ` (almost always)
|
||||
|
||||
The schema includes optional ` + "`" + `model` + "`" + ` and ` + "`" + `provider` + "`" + ` fields. **Omit them.** A user-configurable global default already picks the right model and provider for tracks; setting per-track values bypasses that and is almost always wrong.
|
||||
|
||||
The only time these belong on a track:
|
||||
|
||||
- The user **explicitly** named a model or provider for *this specific track* in their request ("use Claude Opus for this one", "force this track onto OpenAI"). Quote the user's wording back when confirming.
|
||||
|
||||
Things that are **not** reasons to set these:
|
||||
|
||||
- "Tracks should be fast" / "I want a small model" — that's a global preference, not a per-track one. Leave it; the global default exists.
|
||||
- "This track is complex" — write a clearer instruction; don't reach for a different model.
|
||||
- "Just to be safe" / "in case it matters" — this is the antipattern. Leave them out.
|
||||
- The user changed their main chat model — that has nothing to do with tracks. Leave them out.
|
||||
|
||||
When in doubt: omit both fields. Never volunteer them. Never include them in a starter template you suggest. If you find yourself adding them as a sensible default, stop — you're wrong.
|
||||
|
||||
## Choosing a trackId
|
||||
|
||||
- Kebab-case, short, descriptive: ` + "`" + `chicago-time` + "`" + `, ` + "`" + `sfo-weather` + "`" + `, ` + "`" + `hn-top5` + "`" + `, ` + "`" + `btc-usd` + "`" + `.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import type { ToolContext } from "./exec-tool.js";
|
|||
import { generateText } from "ai";
|
||||
import { createProvider } from "../../models/models.js";
|
||||
import { getDefaultModelAndProvider, resolveProviderConfig } from "../../models/defaults.js";
|
||||
import { captureLlmUsage } from "../../analytics/usage.js";
|
||||
import { getCurrentUseCase } from "../../analytics/use_case.js";
|
||||
import { isSignedIn } from "../../account/account.js";
|
||||
import { getAccessToken } from "../../auth/tokens.js";
|
||||
import { API_URL } from "../../config/env.js";
|
||||
|
|
@ -765,6 +767,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
],
|
||||
});
|
||||
|
||||
const ctx = getCurrentUseCase();
|
||||
captureLlmUsage({
|
||||
useCase: ctx?.useCase ?? 'copilot_chat',
|
||||
subUseCase: 'file_parse',
|
||||
...(ctx?.agentName ? { agentName: ctx.agentName } : {}),
|
||||
model: modelId,
|
||||
provider: providerName,
|
||||
usage: response.usage,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileName,
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export const API_URL =
|
||||
process.env.API_URL || 'https://api.x.rowboatlabs.com';
|
||||
process.env.API_URL || 'https://api.x.rowboatlabs.com';
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import path from 'path';
|
|||
import { google } from 'googleapis';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { waitForRunCompletion } from '../agents/utils.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
import { loadUserConfig, updateUserEmail } from '../config/user_config.js';
|
||||
|
|
@ -305,7 +306,12 @@ async function processAgentNotes(): Promise<void> {
|
|||
const timestamp = new Date().toISOString();
|
||||
const message = `Current timestamp: ${timestamp}\n\nProcess the following source material and update the Agent Notes folder accordingly.\n\n${messageParts.join('\n\n')}`;
|
||||
|
||||
const agentRun = await createRun({ agentId: AGENT_ID });
|
||||
const agentRun = await createRun({
|
||||
agentId: AGENT_ID,
|
||||
model: await getKgModel(),
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'agent_notes',
|
||||
});
|
||||
await createMessage(agentRun.id, message);
|
||||
await waitForRunCompletion(agentRun.id);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
export function getRaw(): string {
|
||||
return `---
|
||||
model: anthropic/claude-haiku-4.5
|
||||
tools:
|
||||
workspace-writeFile:
|
||||
type: builtin
|
||||
|
|
|
|||
|
|
@ -252,6 +252,8 @@ async function createNotesFromBatch(
|
|||
// Create a run for the note creation agent
|
||||
const run = await createRun({
|
||||
agentId: NOTE_CREATION_AGENT,
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'build_graph',
|
||||
});
|
||||
const suggestedTopicsContent = readSuggestedTopicsFile();
|
||||
|
||||
|
|
|
|||
|
|
@ -21,14 +21,14 @@ const SECTIONS: Section[] = [
|
|||
instruction:
|
||||
`Write 1-3 sentences of plain markdown giving the user a shoulder-tap about what's next on their calendar today.
|
||||
|
||||
Data: read today's events from calendar_sync/ (workspace-readdir, then workspace-readFile each .json file). Filter to events whose start datetime is today and hasn't started yet.
|
||||
This section refreshes on calendar changes, not on a clock tick — do NOT promise live minute countdowns. Frame urgency in buckets based on the event's start time relative to now:
|
||||
- Start time is in the past or within roughly half an hour → imminent: name the meeting and say it's starting soon (e.g. "Standup is starting — join link in the Calendar section below.").
|
||||
- Start time is later this morning or this afternoon → upcoming: name the meeting and roughly when (e.g. "Design review later this morning." / "1:1 with Sam this afternoon.").
|
||||
- Start time is several hours out or nothing before then → focus block: frame the gap (e.g. "Next up is the all-hands at 3pm — good long focus block until then.").
|
||||
|
||||
Lead based on how soon the next event is:
|
||||
- Under 15 minutes → urgent ("Standup starts in 10 minutes — join link in the Calendar section below.")
|
||||
- Under 2 hours → lead with the event ("Design review in 40 minutes.")
|
||||
- 2+ hours → frame the gap as focus time ("Next up is standup at noon — you've got a solid 3-hour focus block.")
|
||||
Use the event's start time of day ("at 3pm", "this afternoon") rather than a countdown ("in 40 minutes"). Countdowns go stale between syncs.
|
||||
|
||||
Always compute minutes-to-start against the actual current local time — never say "nothing in the next X hours" if an event is in that window.
|
||||
Data: read today's events from calendar_sync/ (workspace-readdir, then workspace-readFile each .json file). Filter to events whose start datetime is today and hasn't ended yet — for finding the next event, pick the earliest upcoming one; if all have passed, treat as clear.
|
||||
|
||||
If you find quick context in knowledge/ that's genuinely useful, add one short clause ("Ramnique pushed the OAuth PR yesterday — might come up"). Use workspace-grep / workspace-readFile conservatively; don't stall on deep research.
|
||||
|
||||
|
|
@ -38,10 +38,6 @@ Plain markdown prose only — no calendar block, no email block, no headings.`,
|
|||
eventMatchCriteria:
|
||||
`Calendar event changes affecting today — new meetings, reschedules, cancellations, meetings starting soon. Skip changes to events on other days.`,
|
||||
active: true,
|
||||
schedule: {
|
||||
type: 'cron',
|
||||
expression: '*/15 * * * *',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -53,16 +49,14 @@ Plain markdown prose only — no calendar block, no email block, no headings.`,
|
|||
|
||||
Data: read calendar_sync/ via workspace-readdir, then workspace-readFile each .json event file. Filter to events occurring today. After 10am local time, drop meetings that have already ended — only include meetings that haven't ended yet.
|
||||
|
||||
This section refreshes on calendar changes, not on a clock tick — the "drop ended meetings" rule applies on each refresh, so an ended meeting disappears the next time any calendar event changes (not exactly on the clock hour). That's fine.
|
||||
|
||||
Always emit the calendar block, even when there are no remaining events (in that case use events: [] and showJoinButton: false). Set showJoinButton: true whenever any event has a conferenceLink.
|
||||
|
||||
After the block, you MAY add one short markdown line per event giving useful prep context pulled from knowledge/ ("Design review: last week we agreed to revisit the type-picker UX."). Keep it tight — one line each, only when meaningful. Skip routine/recurring meetings.`,
|
||||
eventMatchCriteria:
|
||||
`Calendar event changes affecting today — additions, updates, cancellations, reschedules.`,
|
||||
active: true,
|
||||
schedule: {
|
||||
type: 'cron',
|
||||
expression: '0 * * * *',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -72,7 +66,7 @@ After the block, you MAY add one short markdown line per event giving useful pre
|
|||
instruction:
|
||||
`Maintain a digest of email threads worth the user's attention today, rendered as zero or more email blocks (one per thread).
|
||||
|
||||
Event-driven path (primary): the agent message will include a freshly-synced thread's markdown as the event payload. Decide whether THIS thread warrants surfacing. If it's marketing, an auto-notification, a thread already closed out, or otherwise low-signal, skip the update — do NOT call update-track-content. If it's attention-worthy, integrate it into the digest: add a new email block, or update the existing one if the same threadId is already shown.
|
||||
Event-driven path (primary): the agent message will include a "Gmail sync update" digest payload describing one or more freshly-synced threads from a single sync run. The digest lists each thread with its subject, sender, date, threadId, and body. Iterate over every thread in the payload and decide per thread whether it warrants surfacing. Skip marketing, auto-notifications, closed-out threads, and other low-signal mail. For threads that are attention-worthy, integrate them into the existing digest: add a new email block for a new threadId, or update the existing block if the threadId is already shown. If NONE of the threads in the payload are attention-worthy, skip the update — do NOT call update-track-content. Emit at most one update-track-content call that covers the full set of changes from this event.
|
||||
|
||||
Manual path (fallback): with no event payload, scan gmail_sync/ via workspace-readdir (skip sync_state.json and attachments/). Read threads with workspace-readFile. Prioritize threads whose frontmatter action field is "reply" or "respond", plus other high-signal recent threads.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export function getRaw(): string {
|
|||
const defaultEndISO = defaultEnd.toISOString();
|
||||
|
||||
return `---
|
||||
model: anthropic/claude-sonnet-4.6
|
||||
tools:
|
||||
${toolEntries}
|
||||
---
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import { CronExpressionParser } from 'cron-parser';
|
|||
import { generateText } from 'ai';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage, fetchRun } from '../runs/runs.js';
|
||||
import { getKgModel } from '../models/defaults.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';
|
||||
import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js';
|
||||
import { captureLlmUsage } from '../analytics/usage.js';
|
||||
|
||||
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
||||
const INLINE_TASK_AGENT = 'inline_task_agent';
|
||||
|
|
@ -467,7 +469,12 @@ async function processInlineTasks(): Promise<void> {
|
|||
console.log(`[InlineTasks] Running task: "${task.instruction.slice(0, 80)}..."`);
|
||||
|
||||
try {
|
||||
const run = await createRun({ agentId: INLINE_TASK_AGENT });
|
||||
const run = await createRun({
|
||||
agentId: INLINE_TASK_AGENT,
|
||||
model: await getKgModel(),
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'inline_task_run',
|
||||
});
|
||||
|
||||
const message = [
|
||||
`Execute the following instruction from the note "${relativePath}":`,
|
||||
|
|
@ -547,7 +554,12 @@ export async function processRowboatInstruction(
|
|||
scheduleLabel: string | null;
|
||||
response: string | null;
|
||||
}> {
|
||||
const run = await createRun({ agentId: INLINE_TASK_AGENT });
|
||||
const run = await createRun({
|
||||
agentId: INLINE_TASK_AGENT,
|
||||
model: await getKgModel(),
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'inline_task_run',
|
||||
});
|
||||
|
||||
const message = [
|
||||
`Process the following @rowboat instruction from the note "${notePath}":`,
|
||||
|
|
@ -658,6 +670,14 @@ Respond with ONLY valid JSON: either a schedule object or null. No other text.`;
|
|||
prompt: instruction,
|
||||
});
|
||||
|
||||
captureLlmUsage({
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'inline_task_classify',
|
||||
model: config.model,
|
||||
provider: config.provider.flavor,
|
||||
usage: result.usage,
|
||||
});
|
||||
|
||||
let text = result.text.trim();
|
||||
console.log('[classifySchedule] LLM response:', text);
|
||||
// Strip markdown code fences if the LLM wraps the JSON
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { bus } from '../runs/bus.js';
|
||||
import { waitForRunCompletion } from '../agents/utils.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
|
|
@ -71,6 +72,9 @@ async function labelEmailBatch(
|
|||
): Promise<{ runId: string; filesEdited: Set<string> }> {
|
||||
const run = await createRun({
|
||||
agentId: LABELING_AGENT,
|
||||
model: await getKgModel(),
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'label_emails',
|
||||
});
|
||||
|
||||
let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { renderTagSystemForEmails } from './tag_system.js';
|
|||
|
||||
export function getRaw(): string {
|
||||
return `---
|
||||
model: anthropic/claude-haiku-4.5
|
||||
tools:
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { renderNoteEffectRules } from './tag_system.js';
|
|||
|
||||
export function getRaw(): string {
|
||||
return `---
|
||||
model: anthropic/claude-haiku-4.5
|
||||
tools:
|
||||
workspace-writeFile:
|
||||
type: builtin
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { renderTagSystemForNotes } from './tag_system.js';
|
|||
|
||||
export function getRaw(): string {
|
||||
return `---
|
||||
model: anthropic/claude-haiku-4.5
|
||||
tools:
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import { generateText } from 'ai';
|
||||
import { createProvider } from '../models/models.js';
|
||||
import { getDefaultModelAndProvider, resolveProviderConfig } from '../models/defaults.js';
|
||||
import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { captureLlmUsage } from '../analytics/usage.js';
|
||||
|
||||
const CALENDAR_SYNC_DIR = path.join(WorkDir, 'calendar_sync');
|
||||
|
||||
|
|
@ -135,7 +136,8 @@ function loadCalendarEventContext(calendarEventJson: string): string {
|
|||
}
|
||||
|
||||
export async function summarizeMeeting(transcript: string, meetingStartTime?: string, calendarEventJson?: string): Promise<string> {
|
||||
const { model: modelId, provider: providerName } = await getDefaultModelAndProvider();
|
||||
const modelId = await getMeetingNotesModel();
|
||||
const { provider: providerName } = await getDefaultModelAndProvider();
|
||||
const providerConfig = await resolveProviderConfig(providerName);
|
||||
const model = createProvider(providerConfig).languageModel(modelId);
|
||||
|
||||
|
|
@ -156,5 +158,12 @@ export async function summarizeMeeting(transcript: string, meetingStartTime?: st
|
|||
prompt,
|
||||
});
|
||||
|
||||
captureLlmUsage({
|
||||
useCase: 'meeting_note',
|
||||
model: modelId,
|
||||
provider: providerName,
|
||||
usage: result.usage,
|
||||
});
|
||||
|
||||
return result.text.trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,52 @@ import { createEvent } from './track/events.js';
|
|||
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
||||
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
||||
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
|
||||
const MAX_THREADS_IN_DIGEST = 10;
|
||||
const nhm = new NodeHtmlMarkdown();
|
||||
|
||||
interface SyncedThread {
|
||||
threadId: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
function summarizeGmailSync(threads: SyncedThread[]): string {
|
||||
const lines: string[] = [
|
||||
`# Gmail sync update`,
|
||||
``,
|
||||
`${threads.length} new/updated thread${threads.length === 1 ? '' : 's'}.`,
|
||||
``,
|
||||
];
|
||||
|
||||
const shown = threads.slice(0, MAX_THREADS_IN_DIGEST);
|
||||
const hidden = threads.length - shown.length;
|
||||
|
||||
if (shown.length > 0) {
|
||||
lines.push(`## Threads`, ``);
|
||||
for (const { markdown } of shown) {
|
||||
lines.push(markdown.trimEnd(), ``, `---`, ``);
|
||||
}
|
||||
if (hidden > 0) {
|
||||
lines.push(`_…and ${hidden} more thread(s) omitted from digest._`, ``);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function publishGmailSyncEvent(threads: SyncedThread[]): Promise<void> {
|
||||
if (threads.length === 0) return;
|
||||
try {
|
||||
await createEvent({
|
||||
source: 'gmail',
|
||||
type: 'email.synced',
|
||||
createdAt: new Date().toISOString(),
|
||||
payload: summarizeGmailSync(threads),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Gmail] Failed to publish sync event:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wake Signal for Immediate Sync Trigger ---
|
||||
let wakeResolve: (() => void) | null = null;
|
||||
|
||||
|
|
@ -113,14 +157,14 @@ async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string,
|
|||
|
||||
// --- Sync Logic ---
|
||||
|
||||
async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string) {
|
||||
async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string): Promise<SyncedThread | null> {
|
||||
const gmail = google.gmail({ version: 'v1', auth });
|
||||
try {
|
||||
const res = await gmail.users.threads.get({ userId: 'me', id: threadId });
|
||||
const thread = res.data;
|
||||
const messages = thread.messages;
|
||||
|
||||
if (!messages || messages.length === 0) return;
|
||||
if (!messages || messages.length === 0) return null;
|
||||
|
||||
// Subject from first message
|
||||
const firstHeader = messages[0].payload?.headers;
|
||||
|
|
@ -173,15 +217,11 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
|
|||
fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent);
|
||||
console.log(`Synced Thread: ${subject} (${threadId})`);
|
||||
|
||||
await createEvent({
|
||||
source: 'gmail',
|
||||
type: 'email.synced',
|
||||
createdAt: new Date().toISOString(),
|
||||
payload: mdContent,
|
||||
});
|
||||
return { threadId, markdown: mdContent };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error processing thread ${threadId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -262,10 +302,14 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str
|
|||
truncated: limitedThreads.truncated,
|
||||
});
|
||||
|
||||
const synced: SyncedThread[] = [];
|
||||
for (const threadId of threadIds) {
|
||||
await processThread(auth, threadId, syncDir, attachmentsDir);
|
||||
const result = await processThread(auth, threadId, syncDir, attachmentsDir);
|
||||
if (result) synced.push(result);
|
||||
}
|
||||
|
||||
await publishGmailSyncEvent(synced);
|
||||
|
||||
saveState(currentHistoryId, stateFile);
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
|
|
@ -365,10 +409,14 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
|
|||
truncated: limitedThreads.truncated,
|
||||
});
|
||||
|
||||
const synced: SyncedThread[] = [];
|
||||
for (const tid of threadIdList) {
|
||||
await processThread(auth, tid, syncDir, attachmentsDir);
|
||||
const result = await processThread(auth, tid, syncDir, attachmentsDir);
|
||||
if (result) synced.push(result);
|
||||
}
|
||||
|
||||
await publishGmailSyncEvent(synced);
|
||||
|
||||
const profile = await gmail.users.getProfile({ userId: 'me' });
|
||||
saveState(profile.data.historyId!, stateFile);
|
||||
await serviceLogger.log({
|
||||
|
|
@ -565,7 +613,12 @@ function extractBodyFromPayload(payload: Record<string, unknown>): string {
|
|||
return '';
|
||||
}
|
||||
|
||||
async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise<string | null> {
|
||||
interface ComposioThreadResult {
|
||||
synced: SyncedThread | null;
|
||||
newestIsoPlusOne: string | null;
|
||||
}
|
||||
|
||||
async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise<ComposioThreadResult> {
|
||||
let threadResult;
|
||||
try {
|
||||
threadResult = await executeAction(
|
||||
|
|
@ -579,40 +632,34 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin
|
|||
);
|
||||
} catch (error) {
|
||||
console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error);
|
||||
return null;
|
||||
return { synced: null, newestIsoPlusOne: null };
|
||||
}
|
||||
|
||||
if (!threadResult.successful || !threadResult.data) {
|
||||
console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error);
|
||||
return null;
|
||||
return { synced: null, newestIsoPlusOne: null };
|
||||
}
|
||||
|
||||
const data = threadResult.data as Record<string, unknown>;
|
||||
const messages = data.messages as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
let newestDate: Date | null = null;
|
||||
let mdContent: string;
|
||||
let subjectForLog: string;
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
const parsed = parseMessageData(data);
|
||||
const mdContent = `# ${parsed.subject}\n\n` +
|
||||
mdContent = `# ${parsed.subject}\n\n` +
|
||||
`**Thread ID:** ${threadId}\n` +
|
||||
`**Message Count:** 1\n\n---\n\n` +
|
||||
`### From: ${parsed.from}\n` +
|
||||
`**Date:** ${parsed.date}\n\n` +
|
||||
`${parsed.body}\n\n---\n\n`;
|
||||
|
||||
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||
console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`);
|
||||
await createEvent({
|
||||
source: 'gmail',
|
||||
type: 'email.synced',
|
||||
createdAt: new Date().toISOString(),
|
||||
payload: mdContent,
|
||||
});
|
||||
subjectForLog = parsed.subject;
|
||||
newestDate = tryParseDate(parsed.date);
|
||||
} else {
|
||||
const firstParsed = parseMessageData(messages[0]);
|
||||
let mdContent = `# ${firstParsed.subject}\n\n`;
|
||||
mdContent = `# ${firstParsed.subject}\n\n`;
|
||||
mdContent += `**Thread ID:** ${threadId}\n`;
|
||||
mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`;
|
||||
|
||||
|
|
@ -628,19 +675,14 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin
|
|||
newestDate = msgDate;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||
console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`);
|
||||
await createEvent({
|
||||
source: 'gmail',
|
||||
type: 'email.synced',
|
||||
createdAt: new Date().toISOString(),
|
||||
payload: mdContent,
|
||||
});
|
||||
subjectForLog = firstParsed.subject;
|
||||
}
|
||||
|
||||
if (!newestDate) return null;
|
||||
return new Date(newestDate.getTime() + 1000).toISOString();
|
||||
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||
console.log(`[Gmail] Synced Thread: ${subjectForLog} (${threadId})`);
|
||||
|
||||
const newestIsoPlusOne = newestDate ? new Date(newestDate.getTime() + 1000).toISOString() : null;
|
||||
return { synced: { threadId, markdown: mdContent }, newestIsoPlusOne };
|
||||
}
|
||||
|
||||
async function performSyncComposio() {
|
||||
|
|
@ -751,19 +793,22 @@ async function performSyncComposio() {
|
|||
|
||||
let highWaterMark: string | null = state?.last_sync ?? null;
|
||||
let processedCount = 0;
|
||||
const synced: SyncedThread[] = [];
|
||||
for (const threadId of allThreadIds) {
|
||||
// Re-check connection in case user disconnected mid-sync
|
||||
if (!composioAccountsRepo.isConnected('gmail')) {
|
||||
console.log('[Gmail] Account disconnected during sync. Stopping.');
|
||||
return;
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const newestInThread = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR);
|
||||
const result = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR);
|
||||
processedCount++;
|
||||
|
||||
if (newestInThread) {
|
||||
if (!highWaterMark || new Date(newestInThread) > new Date(highWaterMark)) {
|
||||
highWaterMark = newestInThread;
|
||||
if (result.synced) synced.push(result.synced);
|
||||
|
||||
if (result.newestIsoPlusOne) {
|
||||
if (!highWaterMark || new Date(result.newestIsoPlusOne) > new Date(highWaterMark)) {
|
||||
highWaterMark = result.newestIsoPlusOne;
|
||||
}
|
||||
saveComposioState(STATE_FILE, highWaterMark);
|
||||
}
|
||||
|
|
@ -772,6 +817,8 @@ async function performSyncComposio() {
|
|||
}
|
||||
}
|
||||
|
||||
await publishGmailSyncEvent(synced);
|
||||
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: run!.service,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { bus } from '../runs/bus.js';
|
||||
import { waitForRunCompletion } from '../agents/utils.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
|
|
@ -84,6 +85,9 @@ async function tagNoteBatch(
|
|||
): Promise<{ runId: string; filesEdited: Set<string> }> {
|
||||
const run = await createRun({
|
||||
agentId: NOTE_TAGGING_AGENT,
|
||||
model: await getKgModel(),
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'tag_notes',
|
||||
});
|
||||
|
||||
let message = `Tag the following ${files.length} knowledge notes by prepending YAML frontmatter with appropriate tags.\n\n`;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { generateObject } from 'ai';
|
|||
import { trackBlock, PrefixLogger } from '@x/shared';
|
||||
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
|
||||
import { createProvider } from '../../models/models.js';
|
||||
import { getDefaultModelAndProvider, resolveProviderConfig } from '../../models/defaults.js';
|
||||
import { getDefaultModelAndProvider, getTrackBlockModel, resolveProviderConfig } from '../../models/defaults.js';
|
||||
import { captureLlmUsage } from '../../analytics/usage.js';
|
||||
|
||||
const log = new PrefixLogger('TrackRouting');
|
||||
|
||||
|
|
@ -34,9 +35,14 @@ Rules:
|
|||
- For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`;
|
||||
|
||||
async function resolveModel() {
|
||||
const { model, provider } = await getDefaultModelAndProvider();
|
||||
const modelId = await getTrackBlockModel();
|
||||
const { provider } = await getDefaultModelAndProvider();
|
||||
const config = await resolveProviderConfig(provider);
|
||||
return createProvider(config).languageModel(model);
|
||||
return {
|
||||
model: createProvider(config).languageModel(modelId),
|
||||
modelId,
|
||||
providerName: provider,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string {
|
||||
|
|
@ -83,19 +89,26 @@ export async function findCandidates(
|
|||
|
||||
log.log(`Routing event ${event.id} against ${filtered.length} track(s)`);
|
||||
|
||||
const model = await resolveModel();
|
||||
const { model, modelId, providerName } = await resolveModel();
|
||||
const candidateKeys = new Set<string>();
|
||||
|
||||
for (let i = 0; i < filtered.length; i += BATCH_SIZE) {
|
||||
const batch = filtered.slice(i, i + BATCH_SIZE);
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
const result = await generateObject({
|
||||
model,
|
||||
system: ROUTING_SYSTEM_PROMPT,
|
||||
prompt: buildRoutingPrompt(event, batch),
|
||||
schema: trackBlock.Pass1OutputSchema,
|
||||
});
|
||||
for (const c of object.candidates) {
|
||||
captureLlmUsage({
|
||||
useCase: 'track_block',
|
||||
subUseCase: 'routing',
|
||||
model: modelId,
|
||||
provider: providerName,
|
||||
usage: result.usage,
|
||||
});
|
||||
for (const c of result.object.candidates) {
|
||||
candidateKeys.add(trackKey(c.trackId, c.filePath));
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import z from 'zod';
|
||||
import { fetchAll, updateTrackBlock } from './fileops.js';
|
||||
import { createRun, createMessage } from '../../runs/runs.js';
|
||||
import { getTrackBlockModel } from '../../models/defaults.js';
|
||||
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
|
||||
import { trackBus } from './bus.js';
|
||||
import type { TrackStateSchema } from './types.js';
|
||||
|
|
@ -101,8 +102,17 @@ export async function triggerTrackUpdate(
|
|||
|
||||
const contentBefore = track.content;
|
||||
|
||||
// Emit start event — runId is set after agent run is created
|
||||
const agentRun = await createRun({ agentId: 'track-run' });
|
||||
// Per-track model/provider overrides win when set; otherwise fall back
|
||||
// to the configured trackBlockModel default and the run-creation
|
||||
// provider default (signed-in: rowboat; BYOK: active provider).
|
||||
const model = track.track.model ?? await getTrackBlockModel();
|
||||
const agentRun = await createRun({
|
||||
agentId: 'track-run',
|
||||
model,
|
||||
...(track.track.provider ? { provider: track.track.provider } : {}),
|
||||
useCase: 'track_block',
|
||||
subUseCase: 'run',
|
||||
});
|
||||
|
||||
// Set lastRunAt and lastRunId immediately (before agent executes) so
|
||||
// the scheduler's next poll won't re-trigger this track.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import container from "../di/container.js";
|
|||
|
||||
const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
|
||||
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
||||
const SIGNED_IN_KG_MODEL = "anthropic/claude-haiku-4.5";
|
||||
const SIGNED_IN_TRACK_BLOCK_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
/**
|
||||
* The single source of truth for "what model+provider should we use when
|
||||
|
|
@ -51,3 +53,36 @@ export async function resolveProviderConfig(name: string): Promise<z.infer<typeo
|
|||
}
|
||||
throw new Error(`Provider '${name}' is referenced but not configured`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used by knowledge-graph agents (note_creation, labeling_agent, etc.)
|
||||
* when they're the top-level of a run. Signed-in: curated default.
|
||||
* BYOK: user override (`knowledgeGraphModel`) or assistant model.
|
||||
*/
|
||||
export async function getKgModel(): Promise<string> {
|
||||
if (await isSignedIn()) return SIGNED_IN_KG_MODEL;
|
||||
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
|
||||
return cfg.knowledgeGraphModel ?? cfg.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used by track-block runner + routing classifier.
|
||||
* Signed-in: curated default. BYOK: user override (`trackBlockModel`) or
|
||||
* assistant model.
|
||||
*/
|
||||
export async function getTrackBlockModel(): Promise<string> {
|
||||
if (await isSignedIn()) return SIGNED_IN_TRACK_BLOCK_MODEL;
|
||||
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
|
||||
return cfg.trackBlockModel ?? cfg.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used by the meeting-notes summarizer. No special signed-in default —
|
||||
* historically meetings used the assistant model. BYOK: user override
|
||||
* (`meetingNotesModel`) or assistant model.
|
||||
*/
|
||||
export async function getMeetingNotesModel(): Promise<string> {
|
||||
if (await isSignedIn()) return SIGNED_IN_DEFAULT_MODEL;
|
||||
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
|
||||
return cfg.meetingNotesModel ?? cfg.model;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export class FSModelConfigRepo implements IModelConfigRepo {
|
|||
models: config.models,
|
||||
knowledgeGraphModel: config.knowledgeGraphModel,
|
||||
meetingNotesModel: config.meetingNotesModel,
|
||||
trackBlockModel: config.trackBlockModel,
|
||||
};
|
||||
|
||||
const toWrite = { ...config, providers: existingProviders };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
model: anthropic/claude-haiku-4.5
|
||||
tools:
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
model: anthropic/claude-haiku-4.5
|
||||
tools:
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { waitForRunCompletion } from '../agents/utils.js';
|
||||
import {
|
||||
loadConfig,
|
||||
|
|
@ -41,6 +42,9 @@ async function runAgent(agentName: string): Promise<void> {
|
|||
// The agent file is expected to be in the agents directory with the same name
|
||||
const run = await createRun({
|
||||
agentId: agentName,
|
||||
model: await getKgModel(),
|
||||
useCase: 'knowledge_sync',
|
||||
subUseCase: 'pre_built',
|
||||
});
|
||||
|
||||
// Build trigger message with user context
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import path from "path";
|
|||
import fsp from "fs/promises";
|
||||
import fs from "fs";
|
||||
import readline from "readline";
|
||||
import { Run, RunEvent, StartEvent, CreateRunOptions, ListRunsResponse, MessageEvent } from "@x/shared/dist/runs.js";
|
||||
import { Run, RunEvent, StartEvent, ListRunsResponse, MessageEvent, UseCase } from "@x/shared/dist/runs.js";
|
||||
import { getDefaultModelAndProvider } from "../models/defaults.js";
|
||||
|
||||
/**
|
||||
|
|
@ -24,7 +24,13 @@ const LegacyStartEvent = StartEvent.extend({
|
|||
});
|
||||
const ReadRunEvent = RunEvent.or(LegacyStartEvent);
|
||||
|
||||
export type CreateRunRepoOptions = Required<z.infer<typeof CreateRunOptions>>;
|
||||
export type CreateRunRepoOptions = {
|
||||
agentId: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
useCase: z.infer<typeof UseCase>;
|
||||
subUseCase?: string;
|
||||
};
|
||||
|
||||
export interface IRunsRepo {
|
||||
create(options: CreateRunRepoOptions): Promise<z.infer<typeof Run>>;
|
||||
|
|
@ -187,6 +193,8 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
agentName: options.agentId,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
useCase: options.useCase,
|
||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||
subflow: [],
|
||||
ts,
|
||||
};
|
||||
|
|
@ -197,6 +205,8 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
agentId: options.agentId,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
useCase: options.useCase,
|
||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||
log: [start],
|
||||
};
|
||||
}
|
||||
|
|
@ -230,6 +240,8 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
agentId: start.agentName,
|
||||
model: start.model,
|
||||
provider: start.provider,
|
||||
...(start.useCase ? { useCase: start.useCase } : {}),
|
||||
...(start.subUseCase ? { subUseCase: start.subUseCase } : {}),
|
||||
log: events,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,15 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
|||
const defaults = await getDefaultModelAndProvider();
|
||||
const model = opts.model ?? agent.model ?? defaults.model;
|
||||
const provider = opts.provider ?? agent.provider ?? defaults.provider;
|
||||
const useCase = opts.useCase ?? "copilot_chat";
|
||||
|
||||
const run = await repo.create({ agentId: opts.agentId, model, provider });
|
||||
const run = await repo.create({
|
||||
agentId: opts.agentId,
|
||||
model,
|
||||
provider,
|
||||
useCase,
|
||||
...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}),
|
||||
});
|
||||
await bus.publish(run.log[0]);
|
||||
return run;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ const ipcSchemas = {
|
|||
electron: z.string(),
|
||||
}),
|
||||
},
|
||||
'analytics:bootstrap': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
installationId: z.string(),
|
||||
apiUrl: z.string(),
|
||||
}),
|
||||
},
|
||||
'workspace:getRoot': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ export const LlmModelConfig = z.object({
|
|||
model: z.string().optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
})).optional(),
|
||||
// Deprecated: per-run model+provider supersedes these. Kept on the schema so
|
||||
// existing settings/onboarding UIs continue to compile until they're cleaned up.
|
||||
// Per-category model overrides (BYOK only — signed-in users always get
|
||||
// the curated gateway defaults). Read by helpers in core/models/defaults.ts.
|
||||
knowledgeGraphModel: z.string().optional(),
|
||||
meetingNotesModel: z.string().optional(),
|
||||
trackBlockModel: z.string().optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ export const StartEvent = BaseRunEvent.extend({
|
|||
agentName: z.string(),
|
||||
model: z.string(),
|
||||
provider: z.string(),
|
||||
// useCase/subUseCase tag the run for analytics. Optional on read so legacy
|
||||
// run files written before these fields existed still parse cleanly.
|
||||
useCase: z.enum([
|
||||
"copilot_chat",
|
||||
"track_block",
|
||||
"meeting_note",
|
||||
"knowledge_sync",
|
||||
]).optional(),
|
||||
subUseCase: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SpawnSubFlowEvent = BaseRunEvent.extend({
|
||||
|
|
@ -118,6 +127,13 @@ export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
|
|||
response: true,
|
||||
});
|
||||
|
||||
export const UseCase = z.enum([
|
||||
"copilot_chat",
|
||||
"track_block",
|
||||
"meeting_note",
|
||||
"knowledge_sync",
|
||||
]);
|
||||
|
||||
export const Run = z.object({
|
||||
id: z.string(),
|
||||
title: z.string().optional(),
|
||||
|
|
@ -125,6 +141,8 @@ export const Run = z.object({
|
|||
agentId: z.string(),
|
||||
model: z.string(),
|
||||
provider: z.string(),
|
||||
useCase: UseCase.optional(),
|
||||
subUseCase: z.string().optional(),
|
||||
log: z.array(RunEvent),
|
||||
});
|
||||
|
||||
|
|
@ -142,4 +160,6 @@ export const CreateRunOptions = z.object({
|
|||
agentId: z.string(),
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
useCase: UseCase.optional(),
|
||||
subUseCase: z.string().optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export const TrackBlockSchema = z.object({
|
|||
eventMatchCriteria: z.string().optional().describe('When set, this track participates in event-based triggering. Describe what kinds of events should consider this track for an update (e.g. "Emails about Q3 planning"). Omit to disable event triggers — the track will only run on schedule or manually.'),
|
||||
active: z.boolean().default(true).describe('Set false to pause without deleting'),
|
||||
schedule: TrackScheduleSchema.optional(),
|
||||
model: z.string().optional().describe('ADVANCED — leave unset. Per-track LLM model override (e.g. "anthropic/claude-sonnet-4.6"). Only set when the user explicitly asked for a specific model for THIS track. The global default already picks a tuned model for tracks; overriding usually makes things worse, not better.'),
|
||||
provider: z.string().optional().describe('ADVANCED — leave unset. Per-track provider name override (e.g. "openai", "anthropic"). Only set when the user explicitly asked for a specific provider for THIS track. Almost always omitted; the global default flows through correctly.'),
|
||||
lastRunAt: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
lastRunSummary: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue