Compare commits

..

3 Commits

Author SHA1 Message Date
c95c730533 fix: add .ona and drizzle to oxfmt ignore (#119)
oxfmt was reformatting generated drizzle migration snapshots and
crashing on .ona/review/comments.json. Also runs the formatter
across the full codebase.

Co-authored-by: Ona <no-reply@ona.com>
2026-04-12 18:33:46 +01:00
62c8dfe0b1 feat: wrap multi-step DB writes in transactions (#118)
- saveSourceConfig: upsert + credential update run atomically
- updateSourceConfig: SELECT FOR UPDATE prevents lost updates
- Widen Database type to accept transaction handles

Co-authored-by: Ona <no-reply@ona.com>
2026-04-12 15:46:30 +01:00
e54c5d5462 fix: accept credentials in source config upsert (#117)
* fix: unified source config + credentials

Accept optional credentials in PUT /api/sources/:sourceId so the
dashboard can send config and credentials in a single request,
eliminating the race condition between parallel config/credential
updates that left sources uninitialized until server restart.

The existing /credentials endpoint is preserved for independent
credential updates.

Co-authored-by: Ona <no-reply@ona.com>

* refactor: rename upsertSourceConfig to saveSourceConfig

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-04-12 15:17:29 +01:00
45 changed files with 1854 additions and 410 deletions

View File

@@ -8,5 +8,5 @@
"ignoreCase": true,
"newlinesBetween": true
},
"ignorePatterns": [".claude", "fixtures"]
"ignorePatterns": [".claude", ".ona", "drizzle", "fixtures"]
}

View File

@@ -1,5 +1,5 @@
import { Hono } from "hono"
import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
import type { Auth } from "./index.ts"
import type { AuthSession, AuthUser } from "./session.ts"

View File

@@ -1,9 +1,12 @@
import type { PgDatabase } from "drizzle-orm/pg-core"
import { SQL } from "bun"
import { drizzle, type BunSQLDatabase } from "drizzle-orm/bun-sql"
import { drizzle, type BunSQLQueryResultHKT } from "drizzle-orm/bun-sql"
import * as schema from "./schema.ts"
export type Database = BunSQLDatabase<typeof schema>
/** Covers both the top-level drizzle instance and transaction handles. */
export type Database = PgDatabase<BunSQLQueryResultHKT, typeof schema>
export interface DatabaseConnection {
db: Database

View File

@@ -47,5 +47,3 @@ export function createFeedEnhancer(config: FeedEnhancerConfig): FeedEnhancer {
return mergeEnhancement(items, result, currentTime)
}
}

View File

@@ -36,8 +36,7 @@ export function buildPrompt(
for (const item of items) {
const hasUnfilledSlots =
item.slots &&
Object.values(item.slots).some((slot) => slot.content === null)
item.slots && Object.values(item.slots).some((slot) => slot.content === null)
if (hasUnfilledSlots) {
enhanceItems.push({
@@ -79,9 +78,7 @@ export function buildPrompt(
*/
export function hasUnfilledSlots(items: FeedItem[]): boolean {
return items.some(
(item) =>
item.slots &&
Object.values(item.slots).some((slot) => slot.content === null),
(item) => item.slots && Object.values(item.slots).some((slot) => slot.content === null),
)
}
@@ -129,7 +126,20 @@ function extractCalendarEntry(item: FeedItem): CalendarEntry | null {
}
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
] as const
function pad2(n: number): string {
return n.toString().padStart(2, "0")
@@ -144,7 +154,11 @@ function formatDayShort(date: Date): string {
}
function formatDayLabel(date: Date, currentTime: Date): string {
const currentDay = Date.UTC(currentTime.getUTCFullYear(), currentTime.getUTCMonth(), currentTime.getUTCDate())
const currentDay = Date.UTC(
currentTime.getUTCFullYear(),
currentTime.getUTCMonth(),
currentTime.getUTCDate(),
)
const targetDay = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
const diffDays = Math.round((targetDay - currentDay) / (1000 * 60 * 60 * 24))

View File

@@ -135,9 +135,7 @@ describe("schema sync", () => {
// JSON Schema structure matches
const jsonSchema = enhancementResultJsonSchema
expect(Object.keys(jsonSchema.properties).sort()).toEqual(
Object.keys(payload).sort(),
)
expect(Object.keys(jsonSchema.properties).sort()).toEqual(Object.keys(payload).sort())
expect([...jsonSchema.required].sort()).toEqual(Object.keys(payload).sort())
// syntheticItems item schema has the right required fields
@@ -167,11 +165,7 @@ describe("schema sync", () => {
// JSON Schema only allows string or null for slot values
const slotValueSchema =
enhancementResultJsonSchema.properties.slotFills.additionalProperties
.additionalProperties
expect(slotValueSchema.anyOf).toEqual([
{ type: "string" },
{ type: "null" },
])
enhancementResultJsonSchema.properties.slotFills.additionalProperties.additionalProperties
expect(slotValueSchema.anyOf).toEqual([{ type: "string" }, { type: "null" }])
})
})

View File

@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto"
import { describe, expect, test } from "bun:test"
import { randomBytes } from "node:crypto"
import { CredentialEncryptor } from "./crypto.ts"

View File

@@ -81,6 +81,24 @@ mock.module("../sources/user-sources.ts", () => ({
updatedAt: now,
}
},
async findForUpdate(sourceId: string) {
// Delegates to find — row locking is a no-op in tests.
if (mockFindResult !== undefined) return mockFindResult
const now = new Date()
return {
id: crypto.randomUUID(),
userId,
sourceId,
enabled: true,
config: {},
credentials: null,
createdAt: now,
updatedAt: now,
}
},
async updateConfig(_sourceId: string, _update: { enabled?: boolean; config?: unknown }) {
// no-op for tests
},
async upsertConfig(_sourceId: string, _data: { enabled: boolean; config: unknown }) {
// no-op for tests
},
@@ -93,7 +111,9 @@ mock.module("../sources/user-sources.ts", () => ({
}),
}))
const fakeDb = {} as Database
const fakeDb = {
transaction: <T>(fn: (tx: unknown) => Promise<T>) => fn(fakeDb),
} as unknown as Database
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
return {

View File

@@ -126,11 +126,10 @@ export class UserSessionManager {
return
}
// Fetch the existing row for config merging and credential access.
// NOTE: find + updateConfig is not atomic. A concurrent update could
// read stale config. Use SELECT FOR UPDATE or atomic jsonb merge if
// this becomes a problem.
const existingRow = await sources(this.db, userId).find(sourceId)
// Use a transaction with SELECT FOR UPDATE to prevent lost updates
// when concurrent PATCH requests merge config against the same base.
const { existingRow, mergedConfig } = await this.db.transaction(async (tx) => {
const existingRow = await sources(tx, userId).findForUpdate(sourceId)
let mergedConfig: Record<string, unknown> | undefined
if (update.config !== undefined && provider.configSchema) {
@@ -144,11 +143,14 @@ export class UserSessionManager {
}
// Throws SourceNotFoundError if the row doesn't exist
await sources(this.db, userId).updateConfig(sourceId, {
await sources(tx, userId).updateConfig(sourceId, {
enabled: update.enabled,
config: mergedConfig,
})
return { existingRow, mergedConfig }
})
// Refresh the specific source in the active session instead of
// destroying the entire session.
const session = this.sessions.get(userId)
@@ -202,21 +204,24 @@ export class UserSessionManager {
const config = data.config ?? {}
// Fetch existing row before upsert to capture credentials for session refresh.
// For new rows this will be undefined — credentials will be null.
const existingRow = await sources(this.db, userId).find(sourceId)
// Run the upsert + credential update atomically so a failure in
// either step doesn't leave the row in an inconsistent state.
const existingRow = await this.db.transaction(async (tx) => {
const existing = await sources(tx, userId).find(sourceId)
await sources(this.db, userId).upsertConfig(sourceId, {
await sources(tx, userId).upsertConfig(sourceId, {
enabled: data.enabled,
config,
})
// Persist credentials after the upsert so the row exists.
if (data.credentials !== undefined && this.encryptor) {
const encrypted = this.encryptor.encrypt(JSON.stringify(data.credentials))
await sources(this.db, userId).updateCredentials(sourceId, encrypted)
await sources(tx, userId).updateCredentials(sourceId, encrypted)
}
return existing
})
const session = this.sessions.get(userId)
if (session) {
if (!data.enabled) {

View File

@@ -80,6 +80,9 @@ function createInMemoryStore() {
async find(sourceId: string) {
return rows.get(key(userId, sourceId))
},
async findForUpdate(sourceId: string) {
return rows.get(key(userId, sourceId))
},
async updateConfig(sourceId: string, update: { enabled?: boolean; config?: unknown }) {
const existing = rows.get(key(userId, sourceId))
if (!existing) {
@@ -125,7 +128,9 @@ mock.module("../sources/user-sources.ts", () => ({
},
}))
const fakeDb = {} as Database
const fakeDb = {
transaction: <T>(fn: (tx: unknown) => Promise<T>) => fn(fakeDb),
} as unknown as Database
function createApp(providers: FeedSourceProvider[], userId?: string) {
const sessionManager = new UserSessionManager({ providers, db: fakeDb })

View File

@@ -26,6 +26,18 @@ export function sources(db: Database, userId: string) {
return rows[0]
},
/** Like find(), but acquires a row lock to prevent concurrent modifications. Must be called inside a transaction. */
async findForUpdate(sourceId: string) {
const rows = await db
.select()
.from(userSources)
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
.limit(1)
.for("update")
return rows[0]
},
/** Enables a source for the user. Throws if the source row doesn't exist. */
async enableSource(sourceId: string) {
const rows = await db

View File

@@ -55,44 +55,112 @@
"fontFamily": "Inter",
"fontDefinitions": [
{ "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 },
{ "path": "./assets/fonts/Inter_100Thin_Italic.ttf", "weight": 100, "style": "italic" },
{
"path": "./assets/fonts/Inter_100Thin_Italic.ttf",
"weight": 100,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_200ExtraLight.ttf", "weight": 200 },
{ "path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
{
"path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf",
"weight": 200,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_300Light.ttf", "weight": 300 },
{ "path": "./assets/fonts/Inter_300Light_Italic.ttf", "weight": 300, "style": "italic" },
{
"path": "./assets/fonts/Inter_300Light_Italic.ttf",
"weight": 300,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_400Regular.ttf", "weight": 400 },
{ "path": "./assets/fonts/Inter_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
{
"path": "./assets/fonts/Inter_400Regular_Italic.ttf",
"weight": 400,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_500Medium.ttf", "weight": 500 },
{ "path": "./assets/fonts/Inter_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
{
"path": "./assets/fonts/Inter_500Medium_Italic.ttf",
"weight": 500,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_600SemiBold.ttf", "weight": 600 },
{ "path": "./assets/fonts/Inter_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
{
"path": "./assets/fonts/Inter_600SemiBold_Italic.ttf",
"weight": 600,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_700Bold.ttf", "weight": 700 },
{ "path": "./assets/fonts/Inter_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
{
"path": "./assets/fonts/Inter_700Bold_Italic.ttf",
"weight": 700,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_800ExtraBold.ttf", "weight": 800 },
{ "path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
{
"path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf",
"weight": 800,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 },
{ "path": "./assets/fonts/Inter_900Black_Italic.ttf", "weight": 900, "style": "italic" }
{
"path": "./assets/fonts/Inter_900Black_Italic.ttf",
"weight": 900,
"style": "italic"
}
]
},
{
"fontFamily": "Source Serif 4",
"fontDefinitions": [
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 },
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
{
"path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
"weight": 200,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_300Light.ttf", "weight": 300 },
{ "path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf", "weight": 300, "style": "italic" },
{
"path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf",
"weight": 300,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_400Regular.ttf", "weight": 400 },
{ "path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
{
"path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf",
"weight": 400,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_500Medium.ttf", "weight": 500 },
{ "path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
{
"path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf",
"weight": 500,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_600SemiBold.ttf", "weight": 600 },
{ "path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
{
"path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf",
"weight": 600,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_700Bold.ttf", "weight": 700 },
{ "path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
{
"path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf",
"weight": 700,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf", "weight": 800 },
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
{
"path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf",
"weight": 800,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_900Black.ttf", "weight": 900 },
{ "path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf", "weight": 900, "style": "italic" }
{
"path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf",
"weight": 900,
"style": "italic"
}
]
}
]

View File

@@ -27,8 +27,8 @@ export class ApiClient {
(prevInit, middleware) => middleware(url, prevInit),
init,
)
return fetch(this.baseUrl ? new URL(url.toString(), this.baseUrl) : url, finalInit).then((res) =>
Promise.all([Promise.resolve(res), res.json()]),
return fetch(this.baseUrl ? new URL(url.toString(), this.baseUrl) : url, finalInit).then(
(res) => Promise.all([Promise.resolve(res), res.json()]),
)
}
}

View File

@@ -3,13 +3,13 @@ import { useEffect } from "react"
import { ScrollView, View } from "react-native"
import tw from "twrnc"
import { type Showcase } from "@/components/showcase"
import { buttonShowcase } from "@/components/ui/button.showcase"
import { feedCardShowcase } from "@/components/ui/feed-card.showcase"
import { monospaceTextShowcase } from "@/components/ui/monospace-text.showcase"
import { SansSerifText } from "@/components/ui/sans-serif-text"
import { sansSerifTextShowcase } from "@/components/ui/sans-serif-text.showcase"
import { serifTextShowcase } from "@/components/ui/serif-text.showcase"
import { type Showcase } from "@/components/showcase"
import { SansSerifText } from "@/components/ui/sans-serif-text"
const showcases: Record<string, Showcase> = {
button: buttonShowcase,
@@ -41,7 +41,10 @@ export default function ComponentDetailScreen() {
const ShowcaseComponent = showcase.component
return (
<ScrollView style={tw`bg-stone-100 dark:bg-stone-900 flex-1`} contentContainerStyle={tw`px-5 pb-10 pt-4 gap-6`}>
<ScrollView
style={tw`bg-stone-100 dark:bg-stone-900 flex-1`}
contentContainerStyle={tw`px-5 pb-10 pt-4 gap-6`}
>
<ShowcaseComponent />
</ScrollView>
)

View File

@@ -15,7 +15,9 @@ const components = [
export default function ComponentsScreen() {
return (
<View style={tw`flex-1`}>
<View style={tw`mx-4 mt-4 rounded-xl border border-stone-200 dark:border-stone-800 overflow-hidden`}>
<View
style={tw`mx-4 mt-4 rounded-xl border border-stone-200 dark:border-stone-800 overflow-hidden`}
>
<FlatList
data={components}
keyExtractor={(item) => item.name}

View File

@@ -1,8 +1,8 @@
import { View } from "react-native"
import tw from "twrnc"
import { Button } from "./button"
import { type Showcase, Section } from "../showcase"
import { Button } from "./button"
function ButtonShowcase() {
return (
@@ -11,11 +11,7 @@ function ButtonShowcase() {
<Button style={tw`self-start`} label="Press me" />
</Section>
<Section title="Leading icon">
<Button
style={tw`self-start`}
label="Add item"
leadingIcon={<Button.Icon name="plus" />}
/>
<Button style={tw`self-start`} label="Add item" leadingIcon={<Button.Icon name="plus" />} />
</Section>
<Section title="Trailing icon">
<Button

View File

@@ -23,7 +23,11 @@ type ButtonProps = Omit<PressableProps, "children"> & {
export function Button({ style, label, leadingIcon, trailingIcon, ...props }: ButtonProps) {
const hasIcons = leadingIcon != null || trailingIcon != null
const textElement = <SansSerifText style={tw`text-stone-100 dark:text-stone-200 font-medium`}>{label}</SansSerifText>
const textElement = (
<SansSerifText style={tw`text-stone-100 dark:text-stone-200 font-medium`}>
{label}
</SansSerifText>
)
return (
<Pressable style={[tw`rounded-full bg-teal-600 px-4 py-3 w-fit`, style]} {...props}>

View File

@@ -1,11 +1,11 @@
import { View } from "react-native"
import tw from "twrnc"
import { type Showcase, Section } from "../showcase"
import { Button } from "./button"
import { FeedCard } from "./feed-card"
import { SansSerifText } from "./sans-serif-text"
import { SerifText } from "./serif-text"
import { type Showcase, Section } from "../showcase"
function FeedCardShowcase() {
return (

View File

@@ -2,5 +2,10 @@ import { View, type ViewProps } from "react-native"
import tw from "twrnc"
export function FeedCard({ style, ...props }: ViewProps) {
return <View style={[tw`border border-stone-200 dark:border-stone-800 rounded-lg`, style]} {...props} />
return (
<View
style={[tw`border border-stone-200 dark:border-stone-800 rounded-lg`, style]}
{...props}
/>
)
}

View File

@@ -1,8 +1,8 @@
import { View } from "react-native"
import tw from "twrnc"
import { MonospaceText } from "./monospace-text"
import { type Showcase, Section } from "../showcase"
import { MonospaceText } from "./monospace-text"
function MonospaceTextShowcase() {
return (

View File

@@ -3,7 +3,10 @@ import tw from "twrnc"
export function MonospaceText({ children, style, ...props }: TextProps) {
return (
<Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Menlo" }, style]} {...props}>
<Text
style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Menlo" }, style]}
{...props}
>
{children}
</Text>
)

View File

@@ -1,8 +1,8 @@
import { View } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "./sans-serif-text"
import { type Showcase, Section } from "../showcase"
import { SansSerifText } from "./sans-serif-text"
function SansSerifTextShowcase() {
return (

View File

@@ -3,7 +3,10 @@ import tw from "twrnc"
export function SansSerifText({ children, style, ...props }: TextProps) {
return (
<Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Inter" }, style]} {...props}>
<Text
style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Inter" }, style]}
{...props}
>
{children}
</Text>
)

View File

@@ -1,8 +1,8 @@
import { View } from "react-native"
import tw from "twrnc"
import { SerifText } from "./serif-text"
import { type Showcase, Section } from "../showcase"
import { SerifText } from "./serif-text"
function SerifTextShowcase() {
return (

View File

@@ -3,7 +3,10 @@ import tw from "twrnc"
export function SerifText({ children, style, ...props }: TextProps) {
return (
<Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Source Serif 4" }, style]} {...props}>
<Text
style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Source Serif 4" }, style]}
{...props}
>
{children}
</Text>
)

View File

@@ -30,7 +30,8 @@ export const catalog = defineCatalog(schema, {
style: z.string().nullable(),
}),
slots: ["default"],
description: "Bordered card container for feed content. The style prop accepts a twrnc class string.",
description:
"Bordered card container for feed content. The style prop accepts a twrnc class string.",
example: { style: "p-4 gap-2" },
},
SansSerifText: {

View File

@@ -14,12 +14,20 @@ type ButtonIconName = React.ComponentProps<typeof Button.Icon>["name"]
export const { registry } = defineRegistry(catalog, {
components: {
View: ({ props, children }) => <View style={props.style ? tw`${props.style}` : undefined}>{children}</View>,
View: ({ props, children }) => (
<View style={props.style ? tw`${props.style}` : undefined}>{children}</View>
),
Button: ({ props, emit }) => (
<Button
label={props.label}
leadingIcon={props.leadingIcon ? <Button.Icon name={props.leadingIcon as ButtonIconName} /> : undefined}
trailingIcon={props.trailingIcon ? <Button.Icon name={props.trailingIcon as ButtonIconName} /> : undefined}
leadingIcon={
props.leadingIcon ? <Button.Icon name={props.leadingIcon as ButtonIconName} /> : undefined
}
trailingIcon={
props.trailingIcon ? (
<Button.Icon name={props.trailingIcon as ButtonIconName} />
) : undefined
}
onPress={() => emit("press")}
/>
),
@@ -27,13 +35,17 @@ export const { registry } = defineRegistry(catalog, {
<FeedCard style={props.style ? tw`${props.style}` : undefined}>{children}</FeedCard>
),
SansSerifText: ({ props }) => (
<SansSerifText style={props.style ? tw`${props.style}` : undefined}>{props.text}</SansSerifText>
<SansSerifText style={props.style ? tw`${props.style}` : undefined}>
{props.text}
</SansSerifText>
),
SerifText: ({ props }) => (
<SerifText style={props.style ? tw`${props.style}` : undefined}>{props.text}</SerifText>
),
MonospaceText: ({ props }) => (
<MonospaceText style={props.style ? tw`${props.style}` : undefined}>{props.text}</MonospaceText>
<MonospaceText style={props.style ? tw`${props.style}` : undefined}>
{props.text}
</MonospaceText>
),
},
})

View File

@@ -1 +1,131 @@
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[-10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":7,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":7,"st":0}],"meta":{"g":"https://jitter.video"},"op":6,"v":"5.7.4","w":400}
{
"fr": 60,
"h": 400,
"ip": 0,
"layers": [
{
"ind": 3,
"ty": 4,
"parent": 2,
"ks": {},
"ip": 0,
"op": 7,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 53] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 122] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{ "ty": "el", "p": { "a": 0, "k": [160, 53] }, "s": { "a": 0, "k": [320, 106] } },
{
"ty": "st",
"c": { "a": 0, "k": [0.906, 0.898, 0.894] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 2,
"ty": 3,
"parent": 1,
"ks": {
"a": { "a": 0, "k": [160, 53] },
"p": { "a": 0, "k": [200.5, 200] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-30], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 6, "s": [-10], "h": 1 }
]
}
},
"ip": 0,
"op": 7,
"st": 0
},
{
"ind": 5,
"ty": 4,
"parent": 4,
"ks": {},
"ip": 0,
"op": 7,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 53] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 122] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{ "ty": "el", "p": { "a": 0, "k": [160, 53] }, "s": { "a": 0, "k": [320, 106] } },
{
"ty": "st",
"c": { "a": 0, "k": [0.906, 0.898, 0.894] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 4,
"ty": 3,
"parent": 1,
"ks": {
"a": { "a": 0, "k": [160, 53] },
"p": { "a": 0, "k": [200.594, 200.176] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [30], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 6, "s": [10], "h": 1 }
]
}
},
"ip": 0,
"op": 7,
"st": 0
},
{ "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 7, "st": 0 },
{ "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 7, "st": 0 }
],
"meta": { "g": "https://jitter.video" },
"op": 6,
"v": "5.7.4",
"w": 400
}

View File

@@ -1 +1,131 @@
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[-10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":7,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":7,"st":0}],"meta":{"g":"https://jitter.video"},"op":6,"v":"5.7.4","w":400}
{
"fr": 60,
"h": 400,
"ip": 0,
"layers": [
{
"ind": 3,
"ty": 4,
"parent": 2,
"ks": {},
"ip": 0,
"op": 7,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 53] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 122] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{ "ty": "el", "p": { "a": 0, "k": [160, 53] }, "s": { "a": 0, "k": [320, 106] } },
{
"ty": "st",
"c": { "a": 0, "k": [0.11, 0.098, 0.09] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 2,
"ty": 3,
"parent": 1,
"ks": {
"a": { "a": 0, "k": [160, 53] },
"p": { "a": 0, "k": [200.5, 200] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-30], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 6, "s": [-10], "h": 1 }
]
}
},
"ip": 0,
"op": 7,
"st": 0
},
{
"ind": 5,
"ty": 4,
"parent": 4,
"ks": {},
"ip": 0,
"op": 7,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 53] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 122] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{ "ty": "el", "p": { "a": 0, "k": [160, 53] }, "s": { "a": 0, "k": [320, 106] } },
{
"ty": "st",
"c": { "a": 0, "k": [0.11, 0.098, 0.09] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 4,
"ty": 3,
"parent": 1,
"ks": {
"a": { "a": 0, "k": [160, 53] },
"p": { "a": 0, "k": [200.594, 200.176] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [30], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 6, "s": [10], "h": 1 }
]
}
},
"ip": 0,
"op": 7,
"st": 0
},
{ "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 7, "st": 0 },
{ "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 7, "st": 0 }
],
"meta": { "g": "https://jitter.video" },
"op": 6,
"v": "5.7.4",
"w": 400
}

View File

@@ -1 +1,281 @@
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200,200.014]},"r":{"a":1,"k":[{"t":0,"s":[-90],"h":1},{"t":8.4,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"h":1},{"t":37.8,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.094,200.19]},"r":{"a":1,"k":[{"t":0,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":61,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":61,"st":0}],"meta":{"g":"https://jitter.video"},"op":60,"v":"5.7.4","w":400}
{
"fr": 60,
"h": 400,
"ip": 0,
"layers": [
{
"ind": 3,
"ty": 4,
"parent": 2,
"ks": {},
"ip": 0,
"op": 61,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 8.4,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 37.8,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 1],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 8.4,
"s": [320, 1],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [320, 106],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 37.8,
"s": [320, 106],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.906, 0.898, 0.894] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 2,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 8.4,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 37.8,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200, 200.014] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-90], "h": 1 },
{ "t": 8.4, "s": [-90], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 30, "s": [0], "h": 1 },
{ "t": 37.8, "s": [0], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 60, "s": [90], "h": 1 }
]
}
},
"ip": 0,
"op": 61,
"st": 0
},
{
"ind": 5,
"ty": 4,
"parent": 4,
"ks": {},
"ip": 0,
"op": 61,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 1],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [320, 106],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.906, 0.898, 0.894] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 4,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200.094, 200.19] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-90], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 30, "s": [0], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 60, "s": [90], "h": 1 }
]
}
},
"ip": 0,
"op": 61,
"st": 0
},
{ "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 61, "st": 0 },
{ "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 61, "st": 0 }
],
"meta": { "g": "https://jitter.video" },
"op": 60,
"v": "5.7.4",
"w": 400
}

View File

@@ -1 +1,281 @@
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200,200.014]},"r":{"a":1,"k":[{"t":0,"s":[-90],"h":1},{"t":8.4,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"h":1},{"t":37.8,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.094,200.19]},"r":{"a":1,"k":[{"t":0,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":61,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":61,"st":0}],"meta":{"g":"https://jitter.video"},"op":60,"v":"5.7.4","w":400}
{
"fr": 60,
"h": 400,
"ip": 0,
"layers": [
{
"ind": 3,
"ty": 4,
"parent": 2,
"ks": {},
"ip": 0,
"op": 61,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 8.4,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 37.8,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 1],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 8.4,
"s": [320, 1],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [320, 106],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 37.8,
"s": [320, 106],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.11, 0.098, 0.09] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 2,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 8.4,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 37.8,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200, 200.014] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-90], "h": 1 },
{ "t": 8.4, "s": [-90], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 30, "s": [0], "h": 1 },
{ "t": 37.8, "s": [0], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 60, "s": [90], "h": 1 }
]
}
},
"ip": 0,
"op": 61,
"st": 0
},
{
"ind": 5,
"ty": 4,
"parent": 4,
"ks": {},
"ip": 0,
"op": 61,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 1],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [320, 106],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.11, 0.098, 0.09] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 4,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200.094, 200.19] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-90], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 30, "s": [0], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 60, "s": [90], "h": 1 }
]
}
},
"ip": 0,
"op": 61,
"st": 0
},
{ "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 61, "st": 0 },
{ "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 61, "st": 0 }
],
"meta": { "g": "https://jitter.video" },
"op": 60,
"v": "5.7.4",
"w": 400
}

View File

@@ -1 +1,224 @@
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[320,106],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"h":1},{"t":5.28,"s":[-30],"i":{"x":0.001,"y":0.998},"o":{"x":0.349,"y":0}},{"t":30,"s":[-90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":0.999},"o":{"x":0.348,"y":0}},{"t":30,"s":[90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":31,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":31,"st":0}],"meta":{"g":"https://jitter.video"},"op":30,"v":"5.7.4","w":400}
{
"fr": 60,
"h": 400,
"ip": 0,
"layers": [
{
"ind": 3,
"ty": 4,
"parent": 2,
"ks": {},
"ip": 0,
"op": 31,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 5.28,
"s": [160, 53],
"i": { "x": [1, 0.001], "y": [1, 0.998] },
"o": { "x": [0, 0.349], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 106],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 5.28,
"s": [320, 106],
"i": { "x": [1, 0.001], "y": [1, 0.998] },
"o": { "x": [0, 0.349], "y": [0, 0] }
},
{ "t": 30, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.906, 0.898, 0.894] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 2,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 5.28,
"s": [160, 53],
"i": { "x": [1, 0.001], "y": [1, 0.998] },
"o": { "x": [0, 0.349], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200.5, 200] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-30], "h": 1 },
{ "t": 5.28, "s": [-30], "i": { "x": 0.001, "y": 0.998 }, "o": { "x": 0.349, "y": 0 } },
{ "t": 30, "s": [-90], "h": 1 }
]
}
},
"ip": 0,
"op": 31,
"st": 0
},
{
"ind": 5,
"ty": 4,
"parent": 4,
"ks": {},
"ip": 0,
"op": 31,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 0.999] },
"o": { "x": [0, 0.348], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 106],
"i": { "x": [1, 0], "y": [1, 0.999] },
"o": { "x": [0, 0.348], "y": [0, 0] }
},
{ "t": 30, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.906, 0.898, 0.894] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 4,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 0.999] },
"o": { "x": [0, 0.348], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200.594, 200.176] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [30], "i": { "x": 0, "y": 0.999 }, "o": { "x": 0.348, "y": 0 } },
{ "t": 30, "s": [90], "h": 1 }
]
}
},
"ip": 0,
"op": 31,
"st": 0
},
{ "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 31, "st": 0 },
{ "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 31, "st": 0 }
],
"meta": { "g": "https://jitter.video" },
"op": 30,
"v": "5.7.4",
"w": 400
}

View File

@@ -1 +1,224 @@
{"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[320,106],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"h":1},{"t":5.28,"s":[-30],"i":{"x":0.001,"y":0.998},"o":{"x":0.349,"y":0}},{"t":30,"s":[-90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":0.999},"o":{"x":0.348,"y":0}},{"t":30,"s":[90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":31,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":31,"st":0}],"meta":{"g":"https://jitter.video"},"op":30,"v":"5.7.4","w":400}
{
"fr": 60,
"h": 400,
"ip": 0,
"layers": [
{
"ind": 3,
"ty": 4,
"parent": 2,
"ks": {},
"ip": 0,
"op": 31,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 5.28,
"s": [160, 53],
"i": { "x": [1, 0.001], "y": [1, 0.998] },
"o": { "x": [0, 0.349], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 106],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 5.28,
"s": [320, 106],
"i": { "x": [1, 0.001], "y": [1, 0.998] },
"o": { "x": [0, 0.349], "y": [0, 0] }
},
{ "t": 30, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.11, 0.098, 0.09] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 2,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 5.28,
"s": [160, 53],
"i": { "x": [1, 0.001], "y": [1, 0.998] },
"o": { "x": [0, 0.349], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200.5, 200] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-30], "h": 1 },
{ "t": 5.28, "s": [-30], "i": { "x": 0.001, "y": 0.998 }, "o": { "x": 0.349, "y": 0 } },
{ "t": 30, "s": [-90], "h": 1 }
]
}
},
"ip": 0,
"op": 31,
"st": 0
},
{
"ind": 5,
"ty": 4,
"parent": 4,
"ks": {},
"ip": 0,
"op": 31,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 0.999] },
"o": { "x": [0, 0.348], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 106],
"i": { "x": [1, 0], "y": [1, 0.999] },
"o": { "x": [0, 0.348], "y": [0, 0] }
},
{ "t": 30, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.11, 0.098, 0.09] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 4,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 0.999] },
"o": { "x": [0, 0.348], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200.594, 200.176] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [30], "i": { "x": 0, "y": 0.999 }, "o": { "x": 0.348, "y": 0 } },
{ "t": 30, "s": [90], "h": 1 }
]
}
},
"ip": 0,
"op": 31,
"st": 0
},
{ "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 31, "st": 0 },
{ "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 31, "st": 0 }
],
"meta": { "g": "https://jitter.video" },
"op": 30,
"v": "5.7.4",
"w": 400
}

View File

@@ -9,14 +9,14 @@ primary_region = 'lhr'
[build]
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = ['app']
internal_port = 3000
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = ['app']
[[vm]]
memory = '1gb'
cpus = 1
memory_mb = 1024
memory = '1gb'
cpus = 1
memory_mb = 1024

View File

@@ -41,7 +41,7 @@ Sources → Source Graph → FeedEngine
### One harness, not many agents
The "agents" in this doc describe *behaviors*, not separate running processes. A human PA is one person — they don't have a "calendar agent" and a "follow-up agent" in their head. They look at your whole situation and act on whatever matters.
The "agents" in this doc describe _behaviors_, not separate running processes. A human PA is one person — they don't have a "calendar agent" and a "follow-up agent" in their head. They look at your whole situation and act on whatever matters.
AELIS works the same way. One LLM harness receives all feed items, all context, all user memory, and all available tools. It returns a single `FeedEnhancement`. Every behavior (preparation, follow-up, anomaly detection, tone adjustment, cross-source reasoning) is an instruction in the system prompt, not a separate agent.
@@ -50,6 +50,7 @@ The advantage: the LLM sees everything at once. It doesn't need agent-to-agent c
The only separate LLM call is the **Query Agent** — because it's user-initiated and synchronous. But it uses the same system prompt and context. It's the same "person," just responding to a question instead of proactively enhancing the feed.
Everything else is either:
- **Rule-based post-processors** — pure functions, no LLM, run on every refresh
- **The single LLM harness** — runs periodically, produces cached `FeedEnhancement`
- **Background jobs** — daily summary compression, weekly pattern discovery
@@ -57,7 +58,7 @@ Everything else is either:
### Component categories
| Component | What it is | Examples |
|---|---|---|
| ------------------------------ | ----------------------------------------- | --------------------------------------------------------------------- |
| **FeedSource nodes** | Graph participants that produce items | Briefing, Preparation, Anomaly Detection, Follow-up, Social Awareness |
| **Rule-based post-processors** | Pure functions that rerank/filter/group | TimeOfDay, CalendarGrouping, Deduplication, UserAffinity |
| **LLM enhancement harness** | Single background LLM call, cached output | Card rewriting, cross-source synthesis, tone, narrative arcs |
@@ -71,7 +72,7 @@ The LLM harness and post-processors need a unified view of the user's world: cur
`AgentContext` is **not** on the engine. The engine's job is source orchestration — running sources in dependency order, accumulating context, collecting items. It shouldn't know about user preferences, conversation history, or feed snapshots. Those are separate concerns.
`AgentContext` is a separate object that *reads from* the engine and composes its output with other data stores:
`AgentContext` is a separate object that _reads from_ the engine and composes its output with other data stores:
```typescript
interface AgentContext {
@@ -142,7 +143,7 @@ interface FeedEnhancement {
annotations: Record<string, string>
/** Items to group together with a summary card */
groups: Array<{ itemIds: string[], summary: string }>
groups: Array<{ itemIds: string[]; summary: string }>
/** Item IDs to suppress or deprioritize */
suppress: string[]
@@ -185,6 +186,7 @@ These run on every refresh. Fast, deterministic, and cover most of the ranking q
**Anomaly detection.** Compare event start times against the user's historical distribution. A 6am meeting when the user never has meetings before 9am is a statistical outlier — flag it.
**User affinity scoring.** Track implicit signals per source type per time-of-day bucket:
- Dismissals: user swipes away weather cards → decay affinity for weather
- Taps: user taps calendar items frequently → boost affinity for calendar
- Dwell time: user reads TfL alerts carefully → boost
@@ -309,7 +311,7 @@ There are three layers:
None of these have `if` statements. The LLM reads the feed, reads the user's memory, and decides what to say. Add a new source (Spotify, email, tasks) and the LLM automatically incorporates it — no new behavior code needed.
**Infrastructure (plumbing needed, but logic is emergent).** These need tables, APIs, and background jobs. But the *decision-making* — what to extract, when to surface, how to phrase — is all LLM.
**Infrastructure (plumbing needed, but logic is emergent).** These need tables, APIs, and background jobs. But the _decision-making_ — what to extract, when to surface, how to phrase — is all LLM.
- Gentle Follow-up — needs: extraction pipeline after each conversation turn, `commitments` table. The LLM decides what counts as a commitment and when to remind.
- Memory — needs: `memories` table, read/write API. The LLM decides what to remember and how to use it.
@@ -321,7 +323,7 @@ None of these have `if` statements. The LLM reads the feed, reads the user's mem
- Delegation — needs: confirmation flow, write-back infrastructure. The LLM decides what the user wants done.
- Financial Awareness — needs: `financial_events` table, email extraction. The LLM decides what financial events matter.
**Hardcoded rules (fast path, must be deterministic).** These run on every refresh in <10ms. They *should* be rules because they need to be fast and predictable.
**Hardcoded rules (fast path, must be deterministic).** These run on every refresh in <10ms. They _should_ be rules because they need to be fast and predictable.
- User affinity scoring decay/boost math on tap/dismiss events
- Deduplication title + time matching across sources
@@ -419,10 +421,7 @@ class EnhancementManager {
private lastInputHash: string | null = null
private running = false
async enhance(
items: FeedItem[],
context: AgentContext,
): Promise<FeedEnhancement> {
async enhance(items: FeedItem[], context: AgentContext): Promise<FeedEnhancement> {
const hash = computeHash(items, context)
// Nothing changed — return cache
@@ -438,12 +437,14 @@ class EnhancementManager {
// Run in background, update cache when done
this.running = true
this.runHarness(items, context, hash)
.then(enhancement => {
.then((enhancement) => {
this.cache = enhancement
this.lastInputHash = hash
this.notifySubscribers(enhancement)
})
.finally(() => { this.running = false })
.finally(() => {
this.running = false
})
// Return stale cache immediately
return this.cache ?? emptyEnhancement()
@@ -522,7 +523,7 @@ These are `FeedSource` nodes that depend on calendar, tasks, weather, and other
#### Anticipatory Logistics
Works backward from events to tell you what you need to *do* to be ready.
Works backward from events to tell you what you need to _do_ to be ready.
- Flight at 6am → "You need to leave by 4am, which means waking at 3:30. I'd suggest packing tonight."
- Dinner at a new restaurant → "It's a 25-minute walk or 8-minute Uber. Street parking is difficult — there's a car park on the next street."
@@ -579,7 +580,7 @@ Tracks loose ends — things you said but never wrote down as tasks.
- "You told James you'd review his PR — it's been 3 days"
- "You promised to call your mom this weekend"
The key difference from task tracking: this catches things that fell through the cracks *because* they were never formalized.
The key difference from task tracking: this catches things that fell through the cracks _because_ they were never formalized.
**How intent extraction works:**
@@ -614,12 +615,14 @@ Long-term memory of interactions and preferences. Feeds into every other agent.
A persistent profile that builds over time. Not an agent itself — a system that makes every other agent smarter.
Learns from:
- Explicit statements: "I prefer morning meetings"
- Implicit behavior: user always dismisses evening suggestions
- Feedback: user rates suggestions as helpful/not
- Cross-source patterns: always books aisle seats, always picks the cheaper option
Used by:
- Proactive Agent suggests restaurants the user would actually like
- Delegation Agent books the right kind of hotel room
- Summary Agent uses the user's preferred level of detail
@@ -651,17 +654,20 @@ interface DailySummary {
date: string
feedCheckTimes: string[] // when the user opened the feed
itemTypeCounts: Record<string, number> // how many of each type appeared
interactions: Array<{ // what the user tapped/dismissed
interactions: Array<{
// what the user tapped/dismissed
itemType: string
action: "tap" | "dismiss" | "dwell"
time: string
}>
locations: Array<{ // where the user was throughout the day
locations: Array<{
// where the user was throughout the day
lat: number
lng: number
time: string
}>
calendarSummary: Array<{ // what events happened
calendarSummary: Array<{
// what events happened
title: string
startTime: string
endTime: string
@@ -685,7 +691,7 @@ interface DiscoveredPattern {
/** When this pattern is relevant */
relevance: {
daysOfWeek?: number[]
timeRange?: { start: string, end: string }
timeRange?: { start: string; end: string }
conditions?: string[]
}
/** How this should affect the feed */
@@ -717,9 +723,9 @@ Maintains awareness of relationships and surfaces timely nudges.
Needs: contacts with birthday/anniversary data, calendar history for meeting frequency, email/message signals, optionally social media.
This is what makes an assistant feel like it *cares*. Most tools are transactional. This one remembers the people in your life.
This is what makes an assistant feel like it _cares_. Most tools are transactional. This one remembers the people in your life.
Beyond frequency, the assistant can understand relationship *dynamics*:
Beyond frequency, the assistant can understand relationship _dynamics_:
- "You and Sarah always have productive meetings. You and Alex tend to go off-track — maybe set a tighter agenda."
- "You've cancelled on Tom three times — he might be feeling deprioritized."
@@ -785,7 +791,7 @@ This is where the source graph pays off. All the data is already there — the a
#### Tone & Timing
Controls *when* and *how* information is delivered. The difference between useful and annoying.
Controls _when_ and _how_ information is delivered. The difference between useful and annoying.
- Bad news before morning coffee? Hold it.
- Three notifications in a row? Batch them.
@@ -849,6 +855,7 @@ The primary interface. This isn't a feed query tool — it's the person you talk
The user should be able to ask AELIS anything they'd ask a knowledgeable friend. Some questions are about their data. Most aren't.
**About their life (reads from the source graph):**
- "What's on my calendar tomorrow?"
- "When's my next flight?"
- "Do I have any conflicts this week?"
@@ -856,6 +863,7 @@ The user should be able to ask AELIS anything they'd ask a knowledgeable friend.
- "Tell me more about this" (anchored to a feed item)
**About the world (falls through to web search):**
- "How do I unclog a drain?"
- "What should I make with chicken and broccoli?"
- "What's the best way to get from King's Cross to Heathrow?"
@@ -864,6 +872,7 @@ The user should be able to ask AELIS anything they'd ask a knowledgeable friend.
- "What are some good date night restaurants in Shoreditch?"
**Contextual blend (graph + web):**
- "What's the dress code for The Ivy?" (calendar shows dinner there tonight)
- "Will I need an umbrella?" (location + weather, but could also web-search venue for indoor/outdoor)
- "What should I know before my meeting with Acme Corp?" (calendar + web search for company info)
@@ -879,10 +888,12 @@ This is also where intent extraction happens for the Gentle Follow-up Agent. Eve
The backbone for general knowledge. Makes AELIS a person you can ask things, not just a dashboard you look at.
**Reactive (user asks):**
- Recipe ideas, how-to questions, factual lookups, recommendations
- Anything the source graph can't answer
**Proactive (agents trigger):**
- Contextual Preparation enriches calendar events: venue info, attendee backgrounds, parking
- Feed shows a concert → pre-fetches setlist, venue details
- Ambient Context checks for disruptions, closures, news
@@ -949,7 +960,7 @@ Handles tasks the user delegates via natural language.
Requires write access to sources. Confirmation UX for anything destructive or costly.
**Implementation:** Extends the Query Agent. When the LLM determines the user wants to *do* something (not just ask), it calls a delegation tool with structured output: `{ action: "create_reminder" | "schedule_meeting" | "add_task", params: {...} }`. The backend maps this to `executeAction()` on the relevant source. For "find a time that works for both me and Sarah," the agent queries both calendars (requires Sarah to be a known contact with calendar access — or the agent asks the user to share availability). All write actions go through a confirmation step: the backend sends a `delegation.confirm` notification with the proposed action, and the client shows a confirmation UI. The user approves or modifies before execution. Store delegation history for the Follow-up Agent.
**Implementation:** Extends the Query Agent. When the LLM determines the user wants to _do_ something (not just ask), it calls a delegation tool with structured output: `{ action: "create_reminder" | "schedule_meeting" | "add_task", params: {...} }`. The backend maps this to `executeAction()` on the relevant source. For "find a time that works for both me and Sarah," the agent queries both calendars (requires Sarah to be a known contact with calendar access — or the agent asks the user to share availability). All write actions go through a confirmation step: the backend sends a `delegation.confirm` notification with the proposed action, and the client shows a confirmation UI. The user approves or modifies before execution. Store delegation history for the Follow-up Agent.
#### Actions

View File

@@ -238,20 +238,15 @@ The user never waits for the LLM. They see the feed instantly with the previous
The harness serializes items with their unfilled slots into a single prompt. Items without slots are excluded. The LLM sees everything at once and fills whatever slots are relevant.
```typescript
function buildHarnessInput(
items: FeedItem[],
context: AgentContext,
): HarnessInput {
function buildHarnessInput(items: FeedItem[], context: AgentContext): HarnessInput {
const itemsWithSlots = items
.filter(item => item.slots && Object.keys(item.slots).length > 0)
.map(item => ({
.filter((item) => item.slots && Object.keys(item.slots).length > 0)
.map((item) => ({
id: item.id,
type: item.type,
data: item.data,
slots: Object.fromEntries(
Object.entries(item.slots!).map(
([name, slot]) => [name, slot.description]
)
Object.entries(item.slots!).map(([name, slot]) => [name, slot.description]),
),
}))
@@ -280,7 +275,11 @@ The LLM sees:
{
"id": "calendar-event-456",
"type": "calendar-event",
"data": { "title": "Dinner at The Ivy", "startTime": "19:00", "location": "The Ivy, West St" },
"data": {
"title": "Dinner at The Ivy",
"startTime": "19:00",
"location": "The Ivy, West St"
},
"slots": {
"context": "Background on this event, attendees, or previous meetings with these people",
"logistics": "Travel time, parking, directions to the venue",
@@ -315,7 +314,10 @@ A flat map of item ID → slot name → text content. Slots left null are unfill
"id": "briefing-morning",
"type": "briefing",
"data": {},
"ui": { "component": "Text", "props": { "text": "Light afternoon — just your dinner at 7. Rain clears by then." } }
"ui": {
"component": "Text",
"props": { "text": "Light afternoon — just your dinner at 7. Rain clears by then." }
}
}
],
"suppress": [],
@@ -333,10 +335,7 @@ class EnhancementManager {
private lastInputHash: string | null = null
private running = false
async enhance(
items: FeedItem[],
context: AgentContext,
): Promise<EnhancementResult> {
async enhance(items: FeedItem[], context: AgentContext): Promise<EnhancementResult> {
const hash = computeHash(items, context)
if (hash === this.lastInputHash && this.cache) {
@@ -349,12 +348,14 @@ class EnhancementManager {
this.running = true
this.runHarness(items, context)
.then(result => {
.then((result) => {
this.cache = result
this.lastInputHash = hash
this.notifySubscribers(result)
})
.finally(() => { this.running = false })
.finally(() => {
this.running = false
})
return this.cache ?? emptyResult()
}
@@ -373,11 +374,8 @@ interface EnhancementResult {
After the harness runs, the engine merges slot fills into items:
```typescript
function mergeEnhancement(
items: FeedItem[],
result: EnhancementResult,
): FeedItem[] {
return items.map(item => {
function mergeEnhancement(items: FeedItem[], result: EnhancementResult): FeedItem[] {
return items.map((item) => {
const fills = result.slotFills[item.id]
if (!fills || !item.slots) return item

View File

@@ -25,13 +25,13 @@ The backend uses a raw `pg` Pool for Better Auth and has no ORM. We need a persi
A `user_sources` table stores per-user source state:
| Column | Type | Description |
| ------------ | ------------------------ | ------------------------------------------------------------ |
| ------------- | --------------------- | -------------------------------------------------------------- |
| `id` | `uuid` PK | Row ID |
| `user_id` | `text` FK → `user.id` | Owner |
| `source_id` | `text` | Source identifier (e.g., `aelis.tfl`, `aelis.weather`) |
| `enabled` | `boolean` | Whether this source is active in the user's feed |
| `config` | `jsonb` | Source-specific configuration (validated by source at runtime)|
| `credentials`| `bytea` | Encrypted OAuth tokens / secrets (AES-256-GCM) |
| `config` | `jsonb` | Source-specific configuration (validated by source at runtime) |
| `credentials` | `bytea` | Encrypted OAuth tokens / secrets (AES-256-GCM) |
| `created_at` | `timestamp with tz` | Row creation time |
| `updated_at` | `timestamp with tz` | Last modification time |
@@ -51,7 +51,7 @@ A `user_sources` table stores per-user source state:
When a new user is created, seed `user_sources` rows for default sources:
| Source | Default config |
| ------------------ | --------------------------------------------------------------- |
| ---------------- | ----------------------------------------------------------- |
| `aelis.location` | `{}` |
| `aelis.weather` | `{ "units": "metric", "hourlyLimit": 12, "dailyLimit": 7 }` |
| `aelis.tfl` | `{ "lines": <all default lines> }` |
@@ -67,16 +67,22 @@ Each provider receives the Drizzle DB instance and queries `user_sources` intern
```typescript
class TflSourceProvider implements FeedSourceProvider {
constructor(private db: DrizzleDb, private apiKey: string) {}
constructor(
private db: DrizzleDb,
private apiKey: string,
) {}
async feedSourceForUser(userId: string): Promise<TflSource> {
const row = await this.db.select()
const row = await this.db
.select()
.from(userSources)
.where(and(
.where(
and(
eq(userSources.userId, userId),
eq(userSources.sourceId, "aelis.tfl"),
eq(userSources.enabled, true),
))
),
)
.limit(1)
if (!row[0]) {
@@ -210,16 +216,19 @@ _`feed-source-provider.ts`, `user-session-manager.ts`, `engine/http.ts`, and `lo
## Dependencies
**Add:**
- `drizzle-orm`
- `drizzle-kit` (dev)
**Remove:**
- `pg`
- `@types/pg` (dev)
## Environment Variables
**Add to `.env.example`:**
- `CREDENTIALS_ENCRYPTION_KEY` — 32-byte hex or base64 key for AES-256-GCM
## Open Questions (Deferred)

View File

@@ -40,7 +40,7 @@ Source IDs use reverse domain notation. Built-in sources use `aelis.<name>`. Thi
Action IDs are descriptive verb-noun pairs in kebab-case, scoped to their source. The globally unique form is `<sourceId>/<actionId>`.
| Source ID | Action IDs |
| --------------- | -------------------------------------------------------------- |
| ---------------- | -------------------------------------------------------------- |
| `aelis.location` | `update-location` (migrated from `pushLocation()`) |
| `aelis.tfl` | `set-lines-of-interest` (migrated from `setLinesOfInterest()`) |
| `aelis.weather` | _(none)_ |
@@ -241,8 +241,16 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
type: "View",
className: "flex-1",
children: [
{ type: "Text", className: "font-semibold text-black dark:text-white", text: track.name },
{ type: "Text", className: "text-sm text-gray-500 dark:text-gray-400", text: track.artist },
{
type: "Text",
className: "font-semibold text-black dark:text-white",
text: track.name,
},
{
type: "Text",
className: "text-sm text-gray-500 dark:text-gray-400",
text: track.artist,
},
],
},
],
@@ -261,8 +269,8 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
4. `FeedSource.listActions()` is a required method returning `Record<string, ActionDefinition>` (empty record if no actions)
5. `FeedSource.executeAction()` is a required method (no-op for sources without actions)
6. `FeedItem.actions` is an optional readonly array of `ItemAction`
6b. `FeedItem.ui` is an optional json-render tree describing server-driven UI
6c. `FeedItem.slots` is an optional record of named LLM-fillable slots
6b. `FeedItem.ui` is an optional json-render tree describing server-driven UI
6c. `FeedItem.slots` is an optional record of named LLM-fillable slots
7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult`
8. `FeedEngine.listActions()` aggregates actions from all sources
9. Existing tests pass unchanged (all changes are additive)

View File

@@ -7,11 +7,11 @@
"scripts": {
"test": "bun test ."
},
"peerDependencies": {
"@nym.sh/jrx": "*",
"@json-render/core": "*"
},
"dependencies": {
"@standard-schema/spec": "^1.1.0"
},
"peerDependencies": {
"@json-render/core": "*",
"@nym.sh/jrx": "*"
}
}

View File

@@ -1,17 +1,14 @@
import type { Context, FeedEnhancement, FeedItem, FeedPostProcessor } from "@aelis/core"
import { TimeRelevance } from "@aelis/core"
import type { CalDavEventData } from "@aelis/source-caldav"
import type { CalendarEventData } from "@aelis/source-google-calendar"
import type { CurrentWeatherData } from "@aelis/source-weatherkit"
import { TimeRelevance } from "@aelis/core"
import { CalDavFeedItemType } from "@aelis/source-caldav"
import { CalendarFeedItemType } from "@aelis/source-google-calendar"
import { TflFeedItemType } from "@aelis/source-tfl"
import { WeatherFeedItemType } from "@aelis/source-weatherkit"
export const TimePeriod = {
Morning: "morning",
Afternoon: "afternoon",
@@ -28,7 +25,6 @@ export const DayType = {
export type DayType = (typeof DayType)[keyof typeof DayType]
const PRE_MEETING_WINDOW_MS = 30 * 60 * 1000
const TRANSITION_WINDOW_MS = 30 * 60 * 1000
@@ -144,7 +140,6 @@ export function createTimeOfDayEnhancer(options?: TimeOfDayEnhancerOptions): Fee
return timeOfDayEnhancer
}
export function getTimePeriod(date: Date): TimePeriod {
const hour = date.getHours()
if (hour >= 22 || hour < 6) return TimePeriod.Night
@@ -182,7 +177,9 @@ function getNextPeriodBoundary(date: Date): { period: TimePeriod; msUntil: numbe
* Google Calendar uses `startTime`, CalDAV uses `startDate`.
*/
function getEventStartTime(data: CalendarEventData | CalDavEventData): Date {
return "startTime" in data ? (data as CalendarEventData).startTime : (data as CalDavEventData).startDate
return "startTime" in data
? (data as CalendarEventData).startTime
: (data as CalDavEventData).startDate
}
/**
@@ -196,7 +193,6 @@ function hasPrecipitationOrExtreme(item: FeedItem): boolean {
return false
}
interface PreMeetingInfo {
/** IDs of calendar items starting within the pre-meeting window */
upcomingMeetingIds: Set<string>
@@ -225,7 +221,6 @@ function detectPreMeetingItems(items: FeedItem[], now: Date): PreMeetingInfo {
return { upcomingMeetingIds, hasLocationMeeting }
}
function findFirstEventOfDay(items: FeedItem[], now: Date): string | null {
let earliest: { id: string; time: number } | null = null
@@ -252,7 +247,6 @@ function findFirstEventOfDay(items: FeedItem[], now: Date): string | null {
return earliest?.id ?? null
}
function applyMorningWeekday(
items: FeedItem[],
boost: Record<string, number>,
@@ -415,7 +409,6 @@ function applyNight(items: FeedItem[], boost: Record<string, number>, suppress:
}
}
function applyPreMeetingOverrides(
items: FeedItem[],
preMeeting: PreMeetingInfo,
@@ -487,7 +480,6 @@ function applyWindDown(
}
}
function applyTransitionLookahead(
items: FeedItem[],
now: Date,
@@ -544,7 +536,6 @@ function getNextPeriodBoostTargets(period: TimePeriod, dayType: DayType): Readon
return targets
}
function applyWeatherTimeCorrelation(
items: FeedItem[],
period: TimePeriod,
@@ -562,7 +553,11 @@ function applyWeatherTimeCorrelation(
break
}
case WeatherFeedItemType.Current:
if (period === TimePeriod.Morning && dayType === DayType.Weekday && hasPrecipitationOrExtreme(item)) {
if (
period === TimePeriod.Morning &&
dayType === DayType.Weekday &&
hasPrecipitationOrExtreme(item)
) {
boost[item.id] = (boost[item.id] ?? 0) + 0.1
}
if (period === TimePeriod.Evening && hasEveningEventWithLocation) {
@@ -591,5 +586,3 @@ function hasEveningCalendarEventWithLocation(items: FeedItem[], now: Date): bool
return false
}

View File

@@ -7,11 +7,10 @@
* Writes feed items (with slots) to scripts/.cache/feed-items.json for inspection.
*/
import { Context } from "@aelis/core"
import { mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { Context } from "@aelis/core"
import { CalDavSource } from "../src/index.ts"
const serverUrl = prompt("CalDAV server URL:")

View File

@@ -9,8 +9,8 @@
"fetch-fixtures": "bun run scripts/fetch-fixtures.ts"
},
"dependencies": {
"@aelis/core": "workspace:*",
"@aelis/components": "workspace:*",
"@aelis/core": "workspace:*",
"@aelis/source-location": "workspace:*",
"arktype": "^2.1.0"
},

View File

@@ -9,15 +9,14 @@
* Usage: bun packages/aelis-source-weatherkit/scripts/query.ts
*/
import { Context } from "@aelis/core"
import { LocationKey } from "@aelis/source-location"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { createInterface } from "node:readline/promises"
import { Context } from "@aelis/core"
import { LocationKey } from "@aelis/source-location"
import { DefaultWeatherKitClient } from "../src/weatherkit"
import { WeatherSource, Units } from "../src/weather-source"
import { DefaultWeatherKitClient } from "../src/weatherkit"
const SCRIPT_DIR = import.meta.dirname
const CACHE_DIR = join(SCRIPT_DIR, ".cache")

View File

@@ -4,7 +4,12 @@ import { Context } from "@aelis/core"
import { LocationKey } from "@aelis/source-location"
import { describe, expect, test } from "bun:test"
import type { WeatherKitClient, WeatherKitResponse, HourlyForecast, DailyForecast } from "./weatherkit"
import type {
WeatherKitClient,
WeatherKitResponse,
HourlyForecast,
DailyForecast,
} from "./weatherkit"
import fixture from "../fixtures/san-francisco.json"
import { WeatherFeedItemType, type DailyWeatherData, type HourlyWeatherData } from "./feed-items"

View File

@@ -3,7 +3,12 @@ import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
import { LocationKey } from "@aelis/source-location"
import { WeatherFeedItemType, type DailyWeatherEntry, type HourlyWeatherEntry, type WeatherFeedItem } from "./feed-items"
import {
WeatherFeedItemType,
type DailyWeatherEntry,
type HourlyWeatherEntry,
type WeatherFeedItem,
} from "./feed-items"
import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt"
import { WeatherKey, type Weather } from "./weather-context"
import {