Compare commits

..

1 Commits

Author SHA1 Message Date
d949296104 fix: add source to session on cred update
When updateSourceCredentials was called for a source not yet in the
active session (e.g. because credentials were missing at config time),
the source was never instantiated despite being enabled in the DB.

Now, if the source row is enabled but absent from the session, the
source is added instead of skipped.

Co-authored-by: Ona <no-reply@ona.com>
2026-04-12 11:40:56 +00:00
48 changed files with 463 additions and 2055 deletions

View File

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

View File

@@ -20,7 +20,13 @@ import {
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { fetchSourceConfig, pushLocation, replaceSource, updateProviderConfig } from "@/lib/api" import {
fetchSourceConfig,
pushLocation,
replaceSource,
updateProviderConfig,
updateSourceCredentials,
} from "@/lib/api"
interface SourceConfigPanelProps { interface SourceConfigPanelProps {
source: SourceDefinition source: SourceDefinition
@@ -74,24 +80,23 @@ export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps)
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const promises: Promise<void>[] = [
replaceSource(source.id, { enabled, config: getUserConfig() }),
]
const credentialFields = getCredentialFields() const credentialFields = getCredentialFields()
const hasCredentials = Object.values(credentialFields).some( const hasCredentials = Object.values(credentialFields).some(
(v) => typeof v === "string" && v.length > 0, (v) => typeof v === "string" && v.length > 0,
) )
if (hasCredentials) {
if (source.perUserCredentials) {
promises.push(updateSourceCredentials(source.id, credentialFields))
} else {
promises.push(updateProviderConfig(source.id, { credentials: credentialFields }))
}
}
const body: Parameters<typeof replaceSource>[1] = { await Promise.all(promises)
enabled,
config: getUserConfig(),
}
if (hasCredentials && source.perUserCredentials) {
body.credentials = credentialFields
}
await replaceSource(source.id, body)
// For non-per-user credentials (provider-level), still use the admin endpoint.
if (hasCredentials && !source.perUserCredentials) {
await updateProviderConfig(source.id, { credentials: credentialFields })
}
}, },
onSuccess() { onSuccess() {
setDirty({}) setDirty({})

View File

@@ -114,7 +114,7 @@ const sourceDefinitions: SourceDefinition[] = [
timeZone: { timeZone: {
type: "string", type: "string",
label: "Timezone", label: "Timezone",
description: 'IANA timezone for determining "today" (e.g. Europe/London). Defaults to UTC.', description: "IANA timezone for determining \"today\" (e.g. Europe/London). Defaults to UTC.",
}, },
}, },
}, },
@@ -174,7 +174,7 @@ export async function fetchConfigs(): Promise<SourceConfig[]> {
export async function replaceSource( export async function replaceSource(
sourceId: string, sourceId: string,
body: { enabled: boolean; config: unknown; credentials?: Record<string, unknown> }, body: { enabled: boolean; config: unknown },
): Promise<void> { ): Promise<void> {
const res = await fetch(`${serverBase()}/sources/${sourceId}`, { const res = await fetch(`${serverBase()}/sources/${sourceId}`, {
method: "PUT", method: "PUT",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,27 +81,6 @@ mock.module("../sources/user-sources.ts", () => ({
updatedAt: now, 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
},
async updateCredentials(sourceId: string, credentials: Buffer) { async updateCredentials(sourceId: string, credentials: Buffer) {
if (mockUpdateCredentialsError) { if (mockUpdateCredentialsError) {
throw mockUpdateCredentialsError throw mockUpdateCredentialsError
@@ -111,9 +90,7 @@ mock.module("../sources/user-sources.ts", () => ({
}), }),
})) }))
const fakeDb = { const fakeDb = {} as Database
transaction: <T>(fn: (tx: unknown) => Promise<T>) => fn(fakeDb),
} as unknown as Database
function createStubSource(id: string, items: FeedItem[] = []): FeedSource { function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
return { return {
@@ -829,6 +806,31 @@ describe("UserSessionManager.updateSourceCredentials", () => {
expect(receivedCredentials).toEqual({ token: "refreshed" }) expect(receivedCredentials).toEqual({ token: "refreshed" })
}) })
test("adds source to session when source is enabled but not yet in session", async () => {
// Simulate a source that was never added to the session (e.g. credentials
// were missing at config time), but is enabled in the DB.
setEnabledSources([]) // no sources during session creation
const factory = mock(async () => createStubSource("test"))
const provider: FeedSourceProvider = { sourceId: "test", feedSourceForUser: factory }
const manager = new UserSessionManager({
db: fakeDb,
providers: [provider],
credentialEncryptor: testEncryptor,
})
const session = await manager.getOrCreate("user-1")
// Source is NOT in the session
expect(session.hasSource("test")).toBe(false)
// mockFindResult returns an enabled row by default, so the source
// row exists and is enabled in the DB.
await manager.updateSourceCredentials("user-1", "test", { token: "new-token" })
// Source should now be added to the session
expect(session.hasSource("test")).toBe(true)
expect(factory).toHaveBeenCalledTimes(1)
})
test("persists credentials without session refresh when no active session", async () => { test("persists credentials without session refresh when no active session", async () => {
setEnabledSources(["test"]) setEnabledSources(["test"])
const factory = mock(async () => createStubSource("test")) const factory = mock(async () => createStubSource("test"))
@@ -847,121 +849,3 @@ describe("UserSessionManager.updateSourceCredentials", () => {
expect(factory).not.toHaveBeenCalled() expect(factory).not.toHaveBeenCalled()
}) })
}) })
describe("UserSessionManager.saveSourceConfig", () => {
test("upserts config without credentials (existing behavior)", async () => {
setEnabledSources(["test"])
const factory = mock(async () => createStubSource("test"))
const provider: FeedSourceProvider = { sourceId: "test", feedSourceForUser: factory }
const manager = new UserSessionManager({
db: fakeDb,
providers: [provider],
credentialEncryptor: testEncryptor,
})
// Create a session first so we can verify the source is refreshed
await manager.getOrCreate("user-1")
await manager.saveSourceConfig("user-1", "test", {
enabled: true,
config: { key: "value" },
})
// feedSourceForUser called once for session creation, once for upsert refresh
expect(factory).toHaveBeenCalledTimes(2)
// No credentials should have been persisted
expect(mockUpdateCredentialsCalls).toHaveLength(0)
})
test("upserts config with credentials — persists both and passes credentials to source", async () => {
setEnabledSources(["test"])
let receivedCredentials: unknown = null
const factory = mock(async (_userId: string, _config: unknown, creds: unknown) => {
receivedCredentials = creds
return createStubSource("test")
})
const provider: FeedSourceProvider = { sourceId: "test", feedSourceForUser: factory }
const manager = new UserSessionManager({
db: fakeDb,
providers: [provider],
credentialEncryptor: testEncryptor,
})
// Create a session so the source refresh path runs
await manager.getOrCreate("user-1")
const creds = { username: "alice", password: "s3cret" }
await manager.saveSourceConfig("user-1", "test", {
enabled: true,
config: { serverUrl: "https://example.com" },
credentials: creds,
})
// Credentials were encrypted and persisted
expect(mockUpdateCredentialsCalls).toHaveLength(1)
const decrypted = JSON.parse(testEncryptor.decrypt(mockUpdateCredentialsCalls[0]!.credentials))
expect(decrypted).toEqual(creds)
// feedSourceForUser received the provided credentials (not null)
expect(receivedCredentials).toEqual(creds)
})
test("upserts config with credentials adds source to session when not already present", async () => {
// Start with no enabled sources so the session is empty
setEnabledSources([])
const factory = mock(async () => createStubSource("test"))
const provider: FeedSourceProvider = { sourceId: "test", feedSourceForUser: factory }
const manager = new UserSessionManager({
db: fakeDb,
providers: [provider],
credentialEncryptor: testEncryptor,
})
const session = await manager.getOrCreate("user-1")
expect(session.hasSource("test")).toBe(false)
// Set mockFindResult to undefined so find() returns a row (simulating the row was just created by upsertConfig)
await manager.saveSourceConfig("user-1", "test", {
enabled: true,
config: {},
credentials: { token: "abc" },
})
// Source should now be in the session
expect(session.hasSource("test")).toBe(true)
expect(mockUpdateCredentialsCalls).toHaveLength(1)
})
test("throws CredentialStorageUnavailableError when credentials provided without encryptor", async () => {
setEnabledSources(["test"])
const provider = createStubProvider("test")
const manager = new UserSessionManager({
db: fakeDb,
providers: [provider],
// No credentialEncryptor
})
await expect(
manager.saveSourceConfig("user-1", "test", {
enabled: true,
config: {},
credentials: { token: "abc" },
}),
).rejects.toBeInstanceOf(CredentialStorageUnavailableError)
})
test("throws SourceNotFoundError for unknown provider", async () => {
const manager = new UserSessionManager({
db: fakeDb,
providers: [],
credentialEncryptor: testEncryptor,
})
await expect(
manager.saveSourceConfig("user-1", "unknown", {
enabled: true,
config: {},
}),
).rejects.toBeInstanceOf(SourceNotFoundError)
})
})

View File

@@ -126,29 +126,27 @@ export class UserSessionManager {
return return
} }
// Use a transaction with SELECT FOR UPDATE to prevent lost updates // Fetch the existing row for config merging and credential access.
// when concurrent PATCH requests merge config against the same base. // NOTE: find + updateConfig is not atomic. A concurrent update could
const { existingRow, mergedConfig } = await this.db.transaction(async (tx) => { // read stale config. Use SELECT FOR UPDATE or atomic jsonb merge if
const existingRow = await sources(tx, userId).findForUpdate(sourceId) // this becomes a problem.
const existingRow = await sources(this.db, userId).find(sourceId)
let mergedConfig: Record<string, unknown> | undefined let mergedConfig: Record<string, unknown> | undefined
if (update.config !== undefined && provider.configSchema) { if (update.config !== undefined && provider.configSchema) {
const existingConfig = (existingRow?.config ?? {}) as Record<string, unknown> const existingConfig = (existingRow?.config ?? {}) as Record<string, unknown>
mergedConfig = merge({}, existingConfig, update.config) mergedConfig = merge({}, existingConfig, update.config)
const validated = provider.configSchema(mergedConfig) const validated = provider.configSchema(mergedConfig)
if (validated instanceof type.errors) { if (validated instanceof type.errors) {
throw new InvalidSourceConfigError(sourceId, validated.summary) throw new InvalidSourceConfigError(sourceId, validated.summary)
}
} }
}
// Throws SourceNotFoundError if the row doesn't exist // Throws SourceNotFoundError if the row doesn't exist
await sources(tx, userId).updateConfig(sourceId, { await sources(this.db, userId).updateConfig(sourceId, {
enabled: update.enabled, enabled: update.enabled,
config: mergedConfig, config: mergedConfig,
})
return { existingRow, mergedConfig }
}) })
// Refresh the specific source in the active session instead of // Refresh the specific source in the active session instead of
@@ -173,18 +171,13 @@ export class UserSessionManager {
* inserts a new row if one doesn't exist and fully replaces config * inserts a new row if one doesn't exist and fully replaces config
* (no merge). * (no merge).
* *
* When `credentials` is provided, they are encrypted and persisted
* alongside the config in the same flow, avoiding the race condition
* of separate config + credential requests.
*
* @throws {SourceNotFoundError} if the sourceId has no registered provider * @throws {SourceNotFoundError} if the sourceId has no registered provider
* @throws {InvalidSourceConfigError} if config fails schema validation * @throws {InvalidSourceConfigError} if config fails schema validation
* @throws {CredentialStorageUnavailableError} if credentials are provided but no encryptor is configured
*/ */
async saveSourceConfig( async upsertSourceConfig(
userId: string, userId: string,
sourceId: string, sourceId: string,
data: { enabled: boolean; config?: unknown; credentials?: unknown }, data: { enabled: boolean; config?: unknown },
): Promise<void> { ): Promise<void> {
const provider = this.providers.get(sourceId) const provider = this.providers.get(sourceId)
if (!provider) { if (!provider) {
@@ -198,28 +191,15 @@ export class UserSessionManager {
} }
} }
if (data.credentials !== undefined && !this.encryptor) {
throw new CredentialStorageUnavailableError()
}
const config = data.config ?? {} const config = data.config ?? {}
// Run the upsert + credential update atomically so a failure in // Fetch existing row before upsert to capture credentials for session refresh.
// either step doesn't leave the row in an inconsistent state. // For new rows this will be undefined — credentials will be null.
const existingRow = await this.db.transaction(async (tx) => { const existingRow = await sources(this.db, userId).find(sourceId)
const existing = await sources(tx, userId).find(sourceId)
await sources(tx, userId).upsertConfig(sourceId, { await sources(this.db, userId).upsertConfig(sourceId, {
enabled: data.enabled, enabled: data.enabled,
config, config,
})
if (data.credentials !== undefined && this.encryptor) {
const encrypted = this.encryptor.encrypt(JSON.stringify(data.credentials))
await sources(tx, userId).updateCredentials(sourceId, encrypted)
}
return existing
}) })
const session = this.sessions.get(userId) const session = this.sessions.get(userId)
@@ -227,13 +207,9 @@ export class UserSessionManager {
if (!data.enabled) { if (!data.enabled) {
session.removeSource(sourceId) session.removeSource(sourceId)
} else { } else {
// Prefer the just-provided credentials over what was in the DB. const credentials = existingRow?.credentials
let credentials: unknown = null ? this.decryptCredentials(existingRow.credentials)
if (data.credentials !== undefined) { : null
credentials = data.credentials
} else if (existingRow?.credentials) {
credentials = this.decryptCredentials(existingRow.credentials)
}
const source = await provider.feedSourceForUser(userId, config, credentials) const source = await provider.feedSourceForUser(userId, config, credentials)
if (session.hasSource(sourceId)) { if (session.hasSource(sourceId)) {
session.replaceSource(sourceId, source) session.replaceSource(sourceId, source)
@@ -273,11 +249,15 @@ export class UserSessionManager {
// the DB already has the new credentials but the session keeps the old // the DB already has the new credentials but the session keeps the old
// source. The next session creation will pick up the persisted credentials. // source. The next session creation will pick up the persisted credentials.
const session = this.sessions.get(userId) const session = this.sessions.get(userId)
if (session && session.hasSource(sourceId)) { if (session) {
const row = await sources(this.db, userId).find(sourceId) const row = await sources(this.db, userId).find(sourceId)
if (row?.enabled) { if (row?.enabled) {
const source = await provider.feedSourceForUser(userId, row.config ?? {}, credentials) const source = await provider.feedSourceForUser(userId, row.config ?? {}, credentials)
session.replaceSource(sourceId, source) if (session.hasSource(sourceId)) {
session.replaceSource(sourceId, source)
} else {
session.addSource(source)
}
} }
} }
} }

View File

@@ -80,9 +80,6 @@ function createInMemoryStore() {
async find(sourceId: string) { async find(sourceId: string) {
return rows.get(key(userId, sourceId)) 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 }) { async updateConfig(sourceId: string, update: { enabled?: boolean; config?: unknown }) {
const existing = rows.get(key(userId, sourceId)) const existing = rows.get(key(userId, sourceId))
if (!existing) { if (!existing) {
@@ -128,9 +125,7 @@ mock.module("../sources/user-sources.ts", () => ({
}, },
})) }))
const fakeDb = { const fakeDb = {} as Database
transaction: <T>(fn: (tx: unknown) => Promise<T>) => fn(fakeDb),
} as unknown as Database
function createApp(providers: FeedSourceProvider[], userId?: string) { function createApp(providers: FeedSourceProvider[], userId?: string) {
const sessionManager = new UserSessionManager({ providers, db: fakeDb }) const sessionManager = new UserSessionManager({ providers, db: fakeDb })
@@ -743,42 +738,6 @@ describe("PUT /api/sources/:sourceId", () => {
expect(res.status).toBe(204) expect(res.status).toBe(204)
}) })
test("returns 204 when credentials are included alongside config", async () => {
activeStore = createInMemoryStore()
const { app } = createAppWithEncryptor(
[createStubProvider("aelis.weather", weatherConfig)],
MOCK_USER_ID,
)
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "metric" },
credentials: { apiKey: "secret123" },
})
expect(res.status).toBe(204)
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`)
expect(row).toBeDefined()
expect(row!.enabled).toBe(true)
expect(row!.config).toEqual({ units: "metric" })
})
test("returns 503 when credentials are provided but no encryptor is configured", async () => {
activeStore = createInMemoryStore()
// createApp does NOT configure an encryptor
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "metric" },
credentials: { apiKey: "secret123" },
})
expect(res.status).toBe(503)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("not configured")
})
}) })
describe("PUT /api/sources/:sourceId/credentials", () => { describe("PUT /api/sources/:sourceId/credentials", () => {

View File

@@ -34,13 +34,11 @@ const ReplaceSourceConfigRequestBody = type({
"+": "reject", "+": "reject",
enabled: "boolean", enabled: "boolean",
config: "unknown", config: "unknown",
"credentials?": "unknown",
}) })
const ReplaceSourceConfigNoConfigRequestBody = type({ const ReplaceSourceConfigNoConfigRequestBody = type({
"+": "reject", "+": "reject",
enabled: "boolean", enabled: "boolean",
"credentials?": "unknown",
}) })
export function registerSourcesHttpHandlers( export function registerSourcesHttpHandlers(
@@ -163,15 +161,14 @@ async function handleReplaceSource(c: Context<Env>) {
return c.json({ error: parsed.summary }, 400) return c.json({ error: parsed.summary }, 400)
} }
const { enabled, credentials } = parsed const { enabled } = parsed
const config = "config" in parsed ? parsed.config : undefined const config = "config" in parsed ? parsed.config : undefined
const user = c.get("user")! const user = c.get("user")!
try { try {
await sessionManager.saveSourceConfig(user.id, sourceId, { await sessionManager.upsertSourceConfig(user.id, sourceId, {
enabled, enabled,
config, config,
credentials,
}) })
} catch (err) { } catch (err) {
if (err instanceof SourceNotFoundError) { if (err instanceof SourceNotFoundError) {
@@ -180,9 +177,6 @@ async function handleReplaceSource(c: Context<Env>) {
if (err instanceof InvalidSourceConfigError) { if (err instanceof InvalidSourceConfigError) {
return c.json({ error: err.message }, 400) return c.json({ error: err.message }, 400)
} }
if (err instanceof CredentialStorageUnavailableError) {
return c.json({ error: err.message }, 503)
}
throw err throw err
} }

View File

@@ -26,18 +26,6 @@ export function sources(db: Database, userId: string) {
return rows[0] 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. */ /** Enables a source for the user. Throws if the source row doesn't exist. */
async enableSource(sourceId: string) { async enableSource(sourceId: string) {
const rows = await db const rows = await db

View File

@@ -55,112 +55,44 @@
"fontFamily": "Inter", "fontFamily": "Inter",
"fontDefinitions": [ "fontDefinitions": [
{ "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 }, { "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.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.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.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.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.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.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.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.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", "fontFamily": "Source Serif 4",
"fontDefinitions": [ "fontDefinitions": [
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 }, { "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.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.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.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.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.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.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.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), (prevInit, middleware) => middleware(url, prevInit),
init, init,
) )
return fetch(this.baseUrl ? new URL(url.toString(), this.baseUrl) : url, finalInit).then( return fetch(this.baseUrl ? new URL(url.toString(), this.baseUrl) : url, finalInit).then((res) =>
(res) => Promise.all([Promise.resolve(res), res.json()]), Promise.all([Promise.resolve(res), res.json()]),
) )
} }
} }

View File

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

View File

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

View File

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

View File

@@ -23,11 +23,7 @@ type ButtonProps = Omit<PressableProps, "children"> & {
export function Button({ style, label, leadingIcon, trailingIcon, ...props }: ButtonProps) { export function Button({ style, label, leadingIcon, trailingIcon, ...props }: ButtonProps) {
const hasIcons = leadingIcon != null || trailingIcon != null const hasIcons = leadingIcon != null || trailingIcon != null
const textElement = ( const textElement = <SansSerifText style={tw`text-stone-100 dark:text-stone-200 font-medium`}>{label}</SansSerifText>
<SansSerifText style={tw`text-stone-100 dark:text-stone-200 font-medium`}>
{label}
</SansSerifText>
)
return ( return (
<Pressable style={[tw`rounded-full bg-teal-600 px-4 py-3 w-fit`, style]} {...props}> <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 { View } from "react-native"
import tw from "twrnc" import tw from "twrnc"
import { type Showcase, Section } from "../showcase"
import { Button } from "./button" import { Button } from "./button"
import { FeedCard } from "./feed-card" import { FeedCard } from "./feed-card"
import { SansSerifText } from "./sans-serif-text" import { SansSerifText } from "./sans-serif-text"
import { SerifText } from "./serif-text" import { SerifText } from "./serif-text"
import { type Showcase, Section } from "../showcase"
function FeedCardShowcase() { function FeedCardShowcase() {
return ( return (

View File

@@ -2,10 +2,5 @@ import { View, type ViewProps } from "react-native"
import tw from "twrnc" import tw from "twrnc"
export function FeedCard({ style, ...props }: ViewProps) { export function FeedCard({ style, ...props }: ViewProps) {
return ( return <View style={[tw`border border-stone-200 dark:border-stone-800 rounded-lg`, style]} {...props} />
<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 { View } from "react-native"
import tw from "twrnc" import tw from "twrnc"
import { type Showcase, Section } from "../showcase"
import { MonospaceText } from "./monospace-text" import { MonospaceText } from "./monospace-text"
import { type Showcase, Section } from "../showcase"
function MonospaceTextShowcase() { function MonospaceTextShowcase() {
return ( return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,131 +1 @@
{ {"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,131 +1 @@
{ {"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,281 +1 @@
{ {"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,281 +1 @@
{ {"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,224 +1 @@
{ {"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,224 +1 @@
{ {"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] [build]
[http_service] [http_service]
internal_port = 3000 internal_port = 3000
force_https = true force_https = true
auto_stop_machines = 'stop' auto_stop_machines = 'stop'
auto_start_machines = true auto_start_machines = true
min_machines_running = 0 min_machines_running = 0
processes = ['app'] processes = ['app']
[[vm]] [[vm]]
memory = '1gb' memory = '1gb'
cpus = 1 cpus = 1
memory_mb = 1024 memory_mb = 1024

View File

@@ -41,7 +41,7 @@ Sources → Source Graph → FeedEngine
### One harness, not many agents ### 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. 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,21 +50,20 @@ 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. 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: Everything else is either:
- **Rule-based post-processors** — pure functions, no LLM, run on every refresh - **Rule-based post-processors** — pure functions, no LLM, run on every refresh
- **The single LLM harness** — runs periodically, produces cached `FeedEnhancement` - **The single LLM harness** — runs periodically, produces cached `FeedEnhancement`
- **Background jobs** — daily summary compression, weekly pattern discovery - **Background jobs** — daily summary compression, weekly pattern discovery
### Component categories ### Component categories
| Component | What it is | Examples | | Component | What it is | Examples |
| ------------------------------ | ----------------------------------------- | --------------------------------------------------------------------- | |---|---|---|
| **FeedSource nodes** | Graph participants that produce items | Briefing, Preparation, Anomaly Detection, Follow-up, Social Awareness | | **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 | | **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 | | **LLM enhancement harness** | Single background LLM call, cached output | Card rewriting, cross-source synthesis, tone, narrative arcs |
| **Query interface** | Synchronous LLM call, user-initiated | Conversational Q&A, web search, delegation, actions | | **Query interface** | Synchronous LLM call, user-initiated | Conversational Q&A, web search, delegation, actions |
| **Background jobs** | Periodic data processing | Daily summary compression, weekly pattern discovery | | **Background jobs** | Periodic data processing | Daily summary compression, weekly pattern discovery |
| **Persistence** | Stored state that feeds into everything | Memory store, affinity model, conversation history, feed snapshots | | **Persistence** | Stored state that feeds into everything | Memory store, affinity model, conversation history, feed snapshots |
### AgentContext ### AgentContext
@@ -72,32 +71,32 @@ 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 **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 ```typescript
interface AgentContext { interface AgentContext {
/** Current accumulated context from all sources */ /** Current accumulated context from all sources */
context: Context context: Context
/** Recent feed items (last N refreshes or time window) */ /** Recent feed items (last N refreshes or time window) */
recentItems: FeedItem[] recentItems: FeedItem[]
/** Query items from a specific source */ /** Query items from a specific source */
itemsFrom(sourceId: string): FeedItem[] itemsFrom(sourceId: string): FeedItem[]
/** User preference and memory store */ /** User preference and memory store */
preferences: UserPreferences preferences: UserPreferences
/** Conversation history */ /** Conversation history */
conversationHistory: ConversationEntry[] conversationHistory: ConversationEntry[]
} }
// Constructed by composing the engine with persistence layers // Constructed by composing the engine with persistence layers
const agentContext = new AgentContext({ const agentContext = new AgentContext({
engine, // reads current context + items engine, // reads current context + items
memoryStore, // reads/writes user preferences, discovered patterns memoryStore, // reads/writes user preferences, discovered patterns
snapshotStore, // reads feed history for pattern discovery snapshotStore, // reads feed history for pattern discovery
conversationStore, // reads conversation history conversationStore, // reads conversation history
}) })
``` ```
@@ -136,20 +135,20 @@ The enhancement output:
```typescript ```typescript
interface FeedEnhancement { interface FeedEnhancement {
/** New items to inject (briefings, nudges, suggestions) */ /** New items to inject (briefings, nudges, suggestions) */
syntheticItems: FeedItem[] syntheticItems: FeedItem[]
/** Annotations attached to existing items, keyed by item ID */ /** Annotations attached to existing items, keyed by item ID */
annotations: Record<string, string> annotations: Record<string, string>
/** Items to group together with a summary card */ /** 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 */ /** Item IDs to suppress or deprioritize */
suppress: string[] suppress: string[]
/** Ranking hints: item ID → relative importance (0-1) */ /** Ranking hints: item ID → relative importance (0-1) */
rankingHints: Record<string, number> rankingHints: Record<string, number>
} }
``` ```
@@ -186,7 +185,6 @@ 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. **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: **User affinity scoring.** Track implicit signals per source type per time-of-day bucket:
- Dismissals: user swipes away weather cards → decay affinity for weather - Dismissals: user swipes away weather cards → decay affinity for weather
- Taps: user taps calendar items frequently → boost affinity for calendar - Taps: user taps calendar items frequently → boost affinity for calendar
- Dwell time: user reads TfL alerts carefully → boost - Dwell time: user reads TfL alerts carefully → boost
@@ -195,9 +193,9 @@ No LLM needed. A simple decay/boost model:
```typescript ```typescript
interface UserAffinityModel { interface UserAffinityModel {
affinities: Record<string, Record<TimeBucket, number>> affinities: Record<string, Record<TimeBucket, number>>
dismissalDecay: number dismissalDecay: number
tapBoost: number tapBoost: number
} }
``` ```
@@ -311,7 +309,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. 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. - 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. - Memory — needs: `memories` table, read/write API. The LLM decides what to remember and how to use it.
@@ -323,7 +321,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. - 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. - 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 - User affinity scoring decay/boost math on tap/dismiss events
- Deduplication title + time matching across sources - Deduplication title + time matching across sources
@@ -417,38 +415,39 @@ One per user, living in the `FeedEngineManager` on the backend:
```typescript ```typescript
class EnhancementManager { class EnhancementManager {
private cache: FeedEnhancement | null = null private cache: FeedEnhancement | null = null
private lastInputHash: string | null = null private lastInputHash: string | null = null
private running = false private running = false
async enhance(items: FeedItem[], context: AgentContext): Promise<FeedEnhancement> { async enhance(
const hash = computeHash(items, context) items: FeedItem[],
context: AgentContext,
): Promise<FeedEnhancement> {
const hash = computeHash(items, context)
// Nothing changed — return cache // Nothing changed — return cache
if (hash === this.lastInputHash && this.cache) { if (hash === this.lastInputHash && this.cache) {
return this.cache return this.cache
} }
// Already running — return stale cache // Already running — return stale cache
if (this.running) { if (this.running) {
return this.cache ?? emptyEnhancement() return this.cache ?? emptyEnhancement()
} }
// Run in background, update cache when done // Run in background, update cache when done
this.running = true this.running = true
this.runHarness(items, context, hash) this.runHarness(items, context, hash)
.then((enhancement) => { .then(enhancement => {
this.cache = enhancement this.cache = enhancement
this.lastInputHash = hash this.lastInputHash = hash
this.notifySubscribers(enhancement) this.notifySubscribers(enhancement)
}) })
.finally(() => { .finally(() => { this.running = false })
this.running = false
})
// Return stale cache immediately // Return stale cache immediately
return this.cache ?? emptyEnhancement() return this.cache ?? emptyEnhancement()
} }
} }
``` ```
@@ -523,7 +522,7 @@ These are `FeedSource` nodes that depend on calendar, tasks, weather, and other
#### Anticipatory Logistics #### 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." - 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." - 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."
@@ -580,7 +579,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 told James you'd review his PR — it's been 3 days"
- "You promised to call your mom this weekend" - "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:** **How intent extraction works:**
@@ -615,14 +614,12 @@ 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. A persistent profile that builds over time. Not an agent itself — a system that makes every other agent smarter.
Learns from: Learns from:
- Explicit statements: "I prefer morning meetings" - Explicit statements: "I prefer morning meetings"
- Implicit behavior: user always dismisses evening suggestions - Implicit behavior: user always dismisses evening suggestions
- Feedback: user rates suggestions as helpful/not - Feedback: user rates suggestions as helpful/not
- Cross-source patterns: always books aisle seats, always picks the cheaper option - Cross-source patterns: always books aisle seats, always picks the cheaper option
Used by: Used by:
- Proactive Agent suggests restaurants the user would actually like - Proactive Agent suggests restaurants the user would actually like
- Delegation Agent books the right kind of hotel room - Delegation Agent books the right kind of hotel room
- Summary Agent uses the user's preferred level of detail - Summary Agent uses the user's preferred level of detail
@@ -651,30 +648,27 @@ Passive observation. The patterns aren't hardcoded — the LLM discovers them fr
```typescript ```typescript
interface DailySummary { interface DailySummary {
date: string date: string
feedCheckTimes: string[] // when the user opened the feed feedCheckTimes: string[] // when the user opened the feed
itemTypeCounts: Record<string, number> // how many of each type appeared itemTypeCounts: Record<string, number> // how many of each type appeared
interactions: Array<{ interactions: Array<{ // what the user tapped/dismissed
// what the user tapped/dismissed itemType: string
itemType: string action: "tap" | "dismiss" | "dwell"
action: "tap" | "dismiss" | "dwell" time: string
time: string }>
}> locations: Array<{ // where the user was throughout the day
locations: Array<{ lat: number
// where the user was throughout the day lng: number
lat: number time: string
lng: number }>
time: string calendarSummary: Array<{ // what events happened
}> title: string
calendarSummary: Array<{ startTime: string
// what events happened endTime: string
title: string location?: string
startTime: string attendees?: string[]
endTime: string }>
location?: string weatherConditions: string[] // conditions seen throughout the day
attendees?: string[]
}>
weatherConditions: string[] // conditions seen throughout the day
} }
``` ```
@@ -684,20 +678,20 @@ interface DailySummary {
```typescript ```typescript
interface DiscoveredPattern { interface DiscoveredPattern {
/** What the pattern is, in natural language */ /** What the pattern is, in natural language */
description: string description: string
/** How confident (0-1) */ /** How confident (0-1) */
confidence: number confidence: number
/** When this pattern is relevant */ /** When this pattern is relevant */
relevance: { relevance: {
daysOfWeek?: number[] daysOfWeek?: number[]
timeRange?: { start: string; end: string } timeRange?: { start: string, end: string }
conditions?: string[] conditions?: string[]
} }
/** How this should affect the feed */ /** How this should affect the feed */
feedImplication: string feedImplication: string
/** Suggested card to surface when pattern is relevant */ /** Suggested card to surface when pattern is relevant */
suggestedAction?: string suggestedAction?: string
} }
``` ```
@@ -723,9 +717,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. 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 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." - "You've cancelled on Tom three times — he might be feeling deprioritized."
@@ -791,7 +785,7 @@ This is where the source graph pays off. All the data is already there — the a
#### Tone & Timing #### 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. - Bad news before morning coffee? Hold it.
- Three notifications in a row? Batch them. - Three notifications in a row? Batch them.
@@ -855,7 +849,6 @@ 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. 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):** **About their life (reads from the source graph):**
- "What's on my calendar tomorrow?" - "What's on my calendar tomorrow?"
- "When's my next flight?" - "When's my next flight?"
- "Do I have any conflicts this week?" - "Do I have any conflicts this week?"
@@ -863,7 +856,6 @@ 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) - "Tell me more about this" (anchored to a feed item)
**About the world (falls through to web search):** **About the world (falls through to web search):**
- "How do I unclog a drain?" - "How do I unclog a drain?"
- "What should I make with chicken and broccoli?" - "What should I make with chicken and broccoli?"
- "What's the best way to get from King's Cross to Heathrow?" - "What's the best way to get from King's Cross to Heathrow?"
@@ -872,7 +864,6 @@ The user should be able to ask AELIS anything they'd ask a knowledgeable friend.
- "What are some good date night restaurants in Shoreditch?" - "What are some good date night restaurants in Shoreditch?"
**Contextual blend (graph + web):** **Contextual blend (graph + web):**
- "What's the dress code for The Ivy?" (calendar shows dinner there tonight) - "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) - "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) - "What should I know before my meeting with Acme Corp?" (calendar + web search for company info)
@@ -888,12 +879,10 @@ 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. The backbone for general knowledge. Makes AELIS a person you can ask things, not just a dashboard you look at.
**Reactive (user asks):** **Reactive (user asks):**
- Recipe ideas, how-to questions, factual lookups, recommendations - Recipe ideas, how-to questions, factual lookups, recommendations
- Anything the source graph can't answer - Anything the source graph can't answer
**Proactive (agents trigger):** **Proactive (agents trigger):**
- Contextual Preparation enriches calendar events: venue info, attendee backgrounds, parking - Contextual Preparation enriches calendar events: venue info, attendee backgrounds, parking
- Feed shows a concert → pre-fetches setlist, venue details - Feed shows a concert → pre-fetches setlist, venue details
- Ambient Context checks for disruptions, closures, news - Ambient Context checks for disruptions, closures, news
@@ -960,7 +949,7 @@ Handles tasks the user delegates via natural language.
Requires write access to sources. Confirmation UX for anything destructive or costly. 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 #### Actions

View File

@@ -131,19 +131,19 @@ Feed items carry an optional `ui` field containing a json-render tree, and an op
```typescript ```typescript
interface FeedItem<TType, TData> { interface FeedItem<TType, TData> {
id: string id: string
type: TType type: TType
timestamp: Date timestamp: Date
data: TData data: TData
ui?: JsonRenderNode ui?: JsonRenderNode
slots?: Record<string, Slot> slots?: Record<string, Slot>
} }
interface Slot { interface Slot {
/** Tells the LLM what this slot wants — the source writes this */ /** Tells the LLM what this slot wants — the source writes this */
description: string description: string
/** LLM-filled text content, null until enhanced */ /** LLM-filled text content, null until enhanced */
content: string | null content: string | null
} }
``` ```
@@ -238,23 +238,28 @@ 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. 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 ```typescript
function buildHarnessInput(items: FeedItem[], context: AgentContext): HarnessInput { function buildHarnessInput(
const itemsWithSlots = items items: FeedItem[],
.filter((item) => item.slots && Object.keys(item.slots).length > 0) context: AgentContext,
.map((item) => ({ ): HarnessInput {
id: item.id, const itemsWithSlots = items
type: item.type, .filter(item => item.slots && Object.keys(item.slots).length > 0)
data: item.data, .map(item => ({
slots: Object.fromEntries( id: item.id,
Object.entries(item.slots!).map(([name, slot]) => [name, slot.description]), type: item.type,
), data: item.data,
})) slots: Object.fromEntries(
Object.entries(item.slots!).map(
([name, slot]) => [name, slot.description]
)
),
}))
return { return {
items: itemsWithSlots, items: itemsWithSlots,
userMemory: context.preferences, userMemory: context.preferences,
currentTime: new Date().toISOString(), currentTime: new Date().toISOString(),
} }
} }
``` ```
@@ -262,33 +267,29 @@ The LLM sees:
```json ```json
{ {
"items": [ "items": [
{ {
"id": "weather-current-123", "id": "weather-current-123",
"type": "weather-current", "type": "weather-current",
"data": { "temperature": 18, "condition": "cloudy" }, "data": { "temperature": 18, "condition": "cloudy" },
"slots": { "slots": {
"insight": "A short contextual insight about the current weather and how it affects the user's day", "insight": "A short contextual insight about the current weather and how it affects the user's day",
"cross-source": "Connection between weather and the user's calendar events or plans" "cross-source": "Connection between weather and the user's calendar events or plans"
} }
}, },
{ {
"id": "calendar-event-456", "id": "calendar-event-456",
"type": "calendar-event", "type": "calendar-event",
"data": { "data": { "title": "Dinner at The Ivy", "startTime": "19:00", "location": "The Ivy, West St" },
"title": "Dinner at The Ivy", "slots": {
"startTime": "19:00", "context": "Background on this event, attendees, or previous meetings with these people",
"location": "The Ivy, West St" "logistics": "Travel time, parking, directions to the venue",
}, "weather": "Weather conditions relevant to this event's time and location"
"slots": { }
"context": "Background on this event, attendees, or previous meetings with these people", }
"logistics": "Travel time, parking, directions to the venue", ],
"weather": "Weather conditions relevant to this event's time and location" "userMemory": { "commute": "victoria-line", "preference.walking_distance": "1 mile" },
} "currentTime": "2025-02-26T14:30:00Z"
}
],
"userMemory": { "commute": "victoria-line", "preference.walking_distance": "1 mile" },
"currentTime": "2025-02-26T14:30:00Z"
} }
``` ```
@@ -298,30 +299,27 @@ A flat map of item ID → slot name → text content. Slots left null are unfill
```json ```json
{ {
"slotFills": { "slotFills": {
"weather-current-123": { "weather-current-123": {
"insight": "Rain after 3pm — grab a jacket before your walk", "insight": "Rain after 3pm — grab a jacket before your walk",
"cross-source": "Should be dry by 7pm for your dinner at The Ivy" "cross-source": "Should be dry by 7pm for your dinner at The Ivy"
}, },
"calendar-event-456": { "calendar-event-456": {
"context": null, "context": null,
"logistics": "20-minute walk from home — leave by 18:40", "logistics": "20-minute walk from home — leave by 18:40",
"weather": "Rain clears by evening, you'll be fine" "weather": "Rain clears by evening, you'll be fine"
} }
}, },
"syntheticItems": [ "syntheticItems": [
{ {
"id": "briefing-morning", "id": "briefing-morning",
"type": "briefing", "type": "briefing",
"data": {}, "data": {},
"ui": { "ui": { "component": "Text", "props": { "text": "Light afternoon — just your dinner at 7. Rain clears by then." } }
"component": "Text", }
"props": { "text": "Light afternoon — just your dinner at 7. Rain clears by then." } ],
} "suppress": [],
} "rankingHints": {}
],
"suppress": [],
"rankingHints": {}
} }
``` ```
@@ -331,41 +329,42 @@ One per user, living in the `FeedEngineManager` on the backend:
```typescript ```typescript
class EnhancementManager { class EnhancementManager {
private cache: EnhancementResult | null = null private cache: EnhancementResult | null = null
private lastInputHash: string | null = null private lastInputHash: string | null = null
private running = false private running = false
async enhance(items: FeedItem[], context: AgentContext): Promise<EnhancementResult> { async enhance(
const hash = computeHash(items, context) items: FeedItem[],
context: AgentContext,
): Promise<EnhancementResult> {
const hash = computeHash(items, context)
if (hash === this.lastInputHash && this.cache) { if (hash === this.lastInputHash && this.cache) {
return this.cache return this.cache
} }
if (this.running) { if (this.running) {
return this.cache ?? emptyResult() return this.cache ?? emptyResult()
} }
this.running = true this.running = true
this.runHarness(items, context) this.runHarness(items, context)
.then((result) => { .then(result => {
this.cache = result this.cache = result
this.lastInputHash = hash this.lastInputHash = hash
this.notifySubscribers(result) this.notifySubscribers(result)
}) })
.finally(() => { .finally(() => { this.running = false })
this.running = false
})
return this.cache ?? emptyResult() return this.cache ?? emptyResult()
} }
} }
interface EnhancementResult { interface EnhancementResult {
slotFills: Record<string, Record<string, string | null>> slotFills: Record<string, Record<string, string | null>>
syntheticItems: FeedItem[] syntheticItems: FeedItem[]
suppress: string[] suppress: string[]
rankingHints: Record<string, number> rankingHints: Record<string, number>
} }
``` ```
@@ -374,20 +373,23 @@ interface EnhancementResult {
After the harness runs, the engine merges slot fills into items: After the harness runs, the engine merges slot fills into items:
```typescript ```typescript
function mergeEnhancement(items: FeedItem[], result: EnhancementResult): FeedItem[] { function mergeEnhancement(
return items.map((item) => { items: FeedItem[],
const fills = result.slotFills[item.id] result: EnhancementResult,
if (!fills || !item.slots) return item ): FeedItem[] {
return items.map(item => {
const fills = result.slotFills[item.id]
if (!fills || !item.slots) return item
const mergedSlots = { ...item.slots } const mergedSlots = { ...item.slots }
for (const [name, content] of Object.entries(fills)) { for (const [name, content] of Object.entries(fills)) {
if (name in mergedSlots && content !== null) { if (name in mergedSlots && content !== null) {
mergedSlots[name] = { ...mergedSlots[name], content } mergedSlots[name] = { ...mergedSlots[name], content }
} }
} }
return { ...item, slots: mergedSlots } return { ...item, slots: mergedSlots }
}) })
} }
``` ```

View File

@@ -24,16 +24,16 @@ 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: A `user_sources` table stores per-user source state:
| Column | Type | Description | | Column | Type | Description |
| ------------- | --------------------- | -------------------------------------------------------------- | | ------------ | ------------------------ | ------------------------------------------------------------ |
| `id` | `uuid` PK | Row ID | | `id` | `uuid` PK | Row ID |
| `user_id` | `text` FK → `user.id` | Owner | | `user_id` | `text` FK → `user.id` | Owner |
| `source_id` | `text` | Source identifier (e.g., `aelis.tfl`, `aelis.weather`) | | `source_id` | `text` | Source identifier (e.g., `aelis.tfl`, `aelis.weather`) |
| `enabled` | `boolean` | Whether this source is active in the user's feed | | `enabled` | `boolean` | Whether this source is active in the user's feed |
| `config` | `jsonb` | Source-specific configuration (validated by source at runtime) | | `config` | `jsonb` | Source-specific configuration (validated by source at runtime)|
| `credentials` | `bytea` | Encrypted OAuth tokens / secrets (AES-256-GCM) | | `credentials`| `bytea` | Encrypted OAuth tokens / secrets (AES-256-GCM) |
| `created_at` | `timestamp with tz` | Row creation time | | `created_at` | `timestamp with tz` | Row creation time |
| `updated_at` | `timestamp with tz` | Last modification time | | `updated_at` | `timestamp with tz` | Last modification time |
- Unique constraint on `(user_id, source_id)` — one config row per source per user. - Unique constraint on `(user_id, source_id)` — one config row per source per user.
- `config` is a generic `jsonb` column. Each source package exports an arktype schema; the backend provider validates the JSON at source construction time. - `config` is a generic `jsonb` column. Each source package exports an arktype schema; the backend provider validates the JSON at source construction time.
@@ -50,11 +50,11 @@ A `user_sources` table stores per-user source state:
When a new user is created, seed `user_sources` rows for default sources: When a new user is created, seed `user_sources` rows for default sources:
| Source | Default config | | Source | Default config |
| ---------------- | ----------------------------------------------------------- | | ------------------ | --------------------------------------------------------------- |
| `aelis.location` | `{}` | | `aelis.location` | `{}` |
| `aelis.weather` | `{ "units": "metric", "hourlyLimit": 12, "dailyLimit": 7 }` | | `aelis.weather` | `{ "units": "metric", "hourlyLimit": 12, "dailyLimit": 7 }` |
| `aelis.tfl` | `{ "lines": <all default lines> }` | | `aelis.tfl` | `{ "lines": <all default lines> }` |
- Seeding happens via a Better Auth `after` hook on user creation, or via application-level logic after signup. - Seeding happens via a Better Auth `after` hook on user creation, or via application-level logic after signup.
- Sources requiring credentials (Google Calendar, CalDAV) are **not** enabled by default — they require the user to connect an account first. - Sources requiring credentials (Google Calendar, CalDAV) are **not** enabled by default — they require the user to connect an account first.
@@ -67,35 +67,29 @@ Each provider receives the Drizzle DB instance and queries `user_sources` intern
```typescript ```typescript
class TflSourceProvider implements FeedSourceProvider { class TflSourceProvider implements FeedSourceProvider {
constructor( constructor(private db: DrizzleDb, private apiKey: string) {}
private db: DrizzleDb,
private apiKey: string,
) {}
async feedSourceForUser(userId: string): Promise<TflSource> { async feedSourceForUser(userId: string): Promise<TflSource> {
const row = await this.db const row = await this.db.select()
.select() .from(userSources)
.from(userSources) .where(and(
.where( eq(userSources.userId, userId),
and( eq(userSources.sourceId, "aelis.tfl"),
eq(userSources.userId, userId), eq(userSources.enabled, true),
eq(userSources.sourceId, "aelis.tfl"), ))
eq(userSources.enabled, true), .limit(1)
),
)
.limit(1)
if (!row[0]) { if (!row[0]) {
throw new SourceDisabledError("aelis.tfl", userId) throw new SourceDisabledError("aelis.tfl", userId)
} }
const config = tflSourceConfig(row[0].config ?? {}) const config = tflSourceConfig(row[0].config ?? {})
if (config instanceof type.errors) { if (config instanceof type.errors) {
throw new Error(`Invalid TFL config for user ${userId}: ${config.summary}`) throw new Error(`Invalid TFL config for user ${userId}: ${config.summary}`)
} }
return new TflSource({ ...config, apiKey: this.apiKey }) return new TflSource({ ...config, apiKey: this.apiKey })
} }
} }
``` ```
@@ -216,19 +210,16 @@ _`feed-source-provider.ts`, `user-session-manager.ts`, `engine/http.ts`, and `lo
## Dependencies ## Dependencies
**Add:** **Add:**
- `drizzle-orm` - `drizzle-orm`
- `drizzle-kit` (dev) - `drizzle-kit` (dev)
**Remove:** **Remove:**
- `pg` - `pg`
- `@types/pg` (dev) - `@types/pg` (dev)
## Environment Variables ## Environment Variables
**Add to `.env.example`:** **Add to `.env.example`:**
- `CREDENTIALS_ENCRYPTION_KEY` — 32-byte hex or base64 key for AES-256-GCM - `CREDENTIALS_ENCRYPTION_KEY` — 32-byte hex or base64 key for AES-256-GCM
## Open Questions (Deferred) ## Open Questions (Deferred)

View File

@@ -39,14 +39,14 @@ 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>`. 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 | | Source ID | Action IDs |
| ---------------- | -------------------------------------------------------------- | | --------------- | -------------------------------------------------------------- |
| `aelis.location` | `update-location` (migrated from `pushLocation()`) | | `aelis.location` | `update-location` (migrated from `pushLocation()`) |
| `aelis.tfl` | `set-lines-of-interest` (migrated from `setLinesOfInterest()`) | | `aelis.tfl` | `set-lines-of-interest` (migrated from `setLinesOfInterest()`) |
| `aelis.weather` | _(none)_ | | `aelis.weather` | _(none)_ |
| `com.spotify` | `play-track`, `pause-playback`, `skip-track`, `like-track` | | `com.spotify` | `play-track`, `pause-playback`, `skip-track`, `like-track` |
| `aelis.calendar` | `rsvp`, `create-event` | | `aelis.calendar` | `rsvp`, `create-event` |
| `com.todoist` | `complete-task`, `snooze-task` | | `com.todoist` | `complete-task`, `snooze-task` |
This means existing source packages need their `id` updated (e.g., `"location"``"aelis.location"`). This means existing source packages need their `id` updated (e.g., `"location"``"aelis.location"`).
@@ -241,16 +241,8 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
type: "View", type: "View",
className: "flex-1", className: "flex-1",
children: [ children: [
{ { type: "Text", className: "font-semibold text-black dark:text-white", text: track.name },
type: "Text", { type: "Text", className: "text-sm text-gray-500 dark:text-gray-400", text: track.artist },
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,
},
], ],
}, },
], ],
@@ -269,8 +261,8 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
4. `FeedSource.listActions()` is a required method returning `Record<string, ActionDefinition>` (empty record if no actions) 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) 5. `FeedSource.executeAction()` is a required method (no-op for sources without actions)
6. `FeedItem.actions` is an optional readonly array of `ItemAction` 6. `FeedItem.actions` is an optional readonly array of `ItemAction`
6b. `FeedItem.ui` is an optional json-render tree describing server-driven UI 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 6c. `FeedItem.slots` is an optional record of named LLM-fillable slots
7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult` 7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult`
8. `FeedEngine.listActions()` aggregates actions from all sources 8. `FeedEngine.listActions()` aggregates actions from all sources
9. Existing tests pass unchanged (all changes are additive) 9. Existing tests pass unchanged (all changes are additive)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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