chore: sync remaining RealSkills updates and config changes

- Update skills route, activity feed, and stats components
- Update skills embedding and response generator modules
- Update Omega conversation messages route
- Update PRD documentation
- Refresh CLI oclif manifest
- Update gitignore and package configs
This commit is contained in:
Ajax Davis 2026-01-25 03:34:27 +10:00
parent a44f38eda9
commit 234548c6b2
18 changed files with 1397 additions and 679 deletions

3
.gitignore vendored
View file

@ -55,6 +55,9 @@ secrets.json
# turbo
.turbo
# ai sdk devtools
.devtools
# typescript
*.tsbuildinfo

1
apps/web/.gitignore vendored
View file

@ -1,2 +1,3 @@
.vercel
.env*.local
.devtools

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -21,6 +21,7 @@
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.9",
"@ai-sdk/devtools": "^0.0.8",
"@ai-sdk/google": "^3.0.6",
"@ai-sdk/groq": "^3.0.4",
"@ai-sdk/mistral": "^3.0.5",
@ -61,8 +62,8 @@
"remark-gfm": "^4.0.1",
"resend": "^6.7.0",
"sonner": "^2.0.7",
"swr": "^2.2.5",
"streamdown": "^1.6.11",
"swr": "^2.2.5",
"three": "^0.182.0",
"zod": "^4.3.5"
},

View file

@ -8,23 +8,23 @@
* POST - Ask a question (RAG + LLM response)
*/
import { createHash } from 'crypto';
import { prisma } from '@tpmjs/db';
import { NextResponse, type NextRequest } from 'next/server';
import { createHash } from 'crypto';
import { type NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { checkQuestionSimilarity } from '~/lib/ai/skills-embedding';
import { updateSkillGraph, getCollectionSkillsSummary } from '~/lib/ai/skills-graph-updater';
import { getCollectionSkillsSummary, updateSkillGraph } from '~/lib/ai/skills-graph-updater';
import {
generateSkillResponse,
generateFollowupSuggestions,
calculateConfidence,
type CollectionContext,
calculateConfidence,
generateFollowupSuggestions,
generateSkillResponse,
} from '~/lib/ai/skills-response-generator';
import {
seedCollectionSkills,
getSeedingStatus,
type CollectionWithTools,
getSeedingStatus,
seedCollectionSkills,
} from '~/lib/ai/skills-seeder';
export const runtime = 'nodejs';
@ -51,10 +51,7 @@ const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
* Hash agent identity for anonymization
*/
function hashAgentIdentity(ip: string, userAgent: string): string {
return createHash('sha256')
.update(`${ip}:${userAgent}`)
.digest('hex')
.slice(0, 16);
return createHash('sha256').update(`${ip}:${userAgent}`).digest('hex').slice(0, 16);
}
/**
@ -70,10 +67,7 @@ async function loadCollection(
});
if (!user || !user.username) {
return NextResponse.json(
{ success: false, error: 'User not found' },
{ status: 404 }
);
return NextResponse.json({ success: false, error: 'User not found' }, { status: 404 });
}
const collection = await prisma.collection.findFirst({
@ -96,10 +90,7 @@ async function loadCollection(
});
if (!collection) {
return NextResponse.json(
{ success: false, error: 'Collection not found' },
{ status: 404 }
);
return NextResponse.json({ success: false, error: 'Collection not found' }, { status: 404 });
}
if (!collection.isPublic) {
@ -160,9 +151,7 @@ export async function GET(_request: NextRequest, context: RouteContext) {
try {
const { username: rawUsername, slug } = await context.params;
const username = rawUsername.startsWith('@')
? rawUsername.slice(1)
: rawUsername;
const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername;
// Load collection
const result = await loadCollection(username, slug);
@ -225,19 +214,14 @@ export async function POST(request: NextRequest, context: RouteContext) {
try {
const { username: rawUsername, slug } = await context.params;
const username = rawUsername.startsWith('@')
? rawUsername.slice(1)
: rawUsername;
const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername;
// Parse and validate request body
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ success: false, error: 'Invalid JSON body' },
{ status: 400 }
);
return NextResponse.json({ success: false, error: 'Invalid JSON body' }, { status: 400 });
}
const parseResult = PostRequestSchema.safeParse(body);
@ -252,8 +236,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
}
const { question, sessionId, agentName, context: questionContext, tags } =
parseResult.data;
const { question, sessionId, agentName, context: questionContext, tags } = parseResult.data;
// Load collection
const result = await loadCollection(username, slug);
@ -269,10 +252,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
const agentHash = hashAgentIdentity(ip, userAgent);
// Check for similarity / cache hit
const similarityResult = await checkQuestionSimilarity(
question,
collection.id
);
const similarityResult = await checkQuestionSimilarity(question, collection.id);
// If very similar question exists (>95%), return cached answer
if (similarityResult.isCacheHit && similarityResult.cachedAnswer) {
@ -302,8 +282,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
};
// Get session history if session exists
let sessionHistory: Array<{ role: 'user' | 'assistant'; content: string }> =
[];
let sessionHistory: Array<{ role: 'user' | 'assistant'; content: string }> = [];
let activeSessionId = sessionId;
if (sessionId) {
@ -319,9 +298,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
}
// Generate response
const fullQuestion = questionContext
? `${question}\n\nContext: ${questionContext}`
: question;
const fullQuestion = questionContext ? `${question}\n\nContext: ${questionContext}` : question;
const { answer, tokensUsed } = await generateSkillResponse({
question: fullQuestion,
@ -401,11 +378,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
// Generate follow-up suggestions (optional, don't block)
let suggestedFollowups: string[] = [];
try {
suggestedFollowups = await generateFollowupSuggestions(
question,
answer,
collection.name
);
suggestedFollowups = await generateFollowupSuggestions(question, answer, collection.name);
} catch {
// Ignore errors for followups
}

View file

@ -12,7 +12,8 @@
import { Prisma, prisma } from '@tpmjs/db';
import { registryExecuteTool } from '@tpmjs/registry-execute';
import { registrySearchTool } from '@tpmjs/registry-search';
import { jsonSchema, type ModelMessage } from 'ai';
import { jsonSchema, wrapLanguageModel, type ModelMessage } from 'ai';
import { devToolsMiddleware } from '@ai-sdk/devtools';
import { type NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { authenticateRequest } from '~/lib/api-keys/middleware';
@ -20,6 +21,12 @@ import { decryptApiKey } from '~/lib/crypto/api-keys';
import { buildSystemPrompt } from '~/lib/omega/system-prompt';
import { checkRateLimit, type RateLimitConfig } from '~/lib/rate-limit';
// Initialize devtools middleware once at module level (only used in development)
const devtools = process.env.NODE_ENV === 'development' ? devToolsMiddleware() : null;
if (devtools) {
console.log('[Omega] AI SDK DevTools middleware initialized');
}
/**
* Warning about missing environment variables
*/
@ -556,7 +563,16 @@ Remember: Your value is in EXECUTING tools to get real results, not just describ
}
const openai = createOpenAI({ apiKey });
const model = openai('gpt-4.1-mini');
const baseModel = openai('gpt-4.1-mini');
// Wrap with devtools middleware in development
const model = devtools
? wrapLanguageModel({ model: baseModel, middleware: devtools })
: baseModel;
if (devtools) {
console.log('[Omega] Model wrapped with DevTools middleware');
}
// Create SSE stream
const stream = new ReadableStream({

View file

@ -5,7 +5,7 @@
*/
import { prisma } from '@tpmjs/db';
import { NextResponse, type NextRequest } from 'next/server';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -17,10 +17,7 @@ export async function GET(request: NextRequest) {
const limit = Math.min(50, Math.max(1, parseInt(limitParam || '10', 10)));
if (!collectionId) {
return NextResponse.json(
{ error: 'collectionId is required' },
{ status: 400 }
);
return NextResponse.json({ error: 'collectionId is required' }, { status: 400 });
}
try {
@ -31,17 +28,11 @@ export async function GET(request: NextRequest) {
});
if (!collection) {
return NextResponse.json(
{ error: 'Collection not found' },
{ status: 404 }
);
return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
}
if (!collection.isPublic) {
return NextResponse.json(
{ error: 'Collection is not public' },
{ status: 403 }
);
return NextResponse.json({ error: 'Collection is not public' }, { status: 403 });
}
// Fetch recent questions with skill links (anonymized - no agent info)
@ -73,9 +64,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ questions });
} catch (error) {
console.error('[Skills Activity Error]:', error);
return NextResponse.json(
{ error: 'Failed to fetch activity' },
{ status: 500 }
);
return NextResponse.json({ error: 'Failed to fetch activity' }, { status: 500 });
}
}

View file

@ -5,7 +5,7 @@
*/
import { prisma } from '@tpmjs/db';
import { NextResponse, type NextRequest } from 'next/server';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -15,10 +15,7 @@ export async function GET(request: NextRequest) {
const collectionId = searchParams.get('collectionId');
if (!collectionId) {
return NextResponse.json(
{ error: 'collectionId is required' },
{ status: 400 }
);
return NextResponse.json({ error: 'collectionId is required' }, { status: 400 });
}
try {
@ -29,17 +26,11 @@ export async function GET(request: NextRequest) {
});
if (!collection) {
return NextResponse.json(
{ error: 'Collection not found' },
{ status: 404 }
);
return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
}
if (!collection.isPublic) {
return NextResponse.json(
{ error: 'Collection is not public' },
{ status: 403 }
);
return NextResponse.json({ error: 'Collection is not public' }, { status: 403 });
}
// Fetch stats in parallel
@ -65,9 +56,6 @@ export async function GET(request: NextRequest) {
});
} catch (error) {
console.error('[Skills Stats Error]:', error);
return NextResponse.json(
{ error: 'Failed to fetch stats' },
{ status: 500 }
);
return NextResponse.json({ error: 'Failed to fetch stats' }, { status: 500 });
}
}

View file

@ -251,11 +251,7 @@ export default function SkillsPage(): React.ReactElement {
/>
<h4 className="font-semibold mt-4">Example Response</h4>
<CodeBlock
code={exampleResponseJson}
language="json"
showCopy={true}
/>
<CodeBlock code={exampleResponseJson} language="json" showCopy={true} />
</CardContent>
</Card>
@ -353,7 +349,9 @@ const followUp = await askSkills(
<h4 className="font-semibold text-sm mb-2">Tips for Better Responses</h4>
<ul className="text-foreground-secondary text-sm space-y-1">
<li> Be specific about what you&apos;re trying to accomplish</li>
<li> Include relevant context in the <code>context</code> field</li>
<li>
Include relevant context in the <code>context</code> field
</li>
<li> Use tags to hint at the problem domain</li>
<li> Use sessions for related follow-up questions</li>
</ul>
@ -413,8 +411,8 @@ const followUp = await askSkills(
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
When a collection&apos;s skills endpoint is accessed for the first time, it automatically
seeds with synthetic questions generated from:
When a collection&apos;s skills endpoint is accessed for the first time, it
automatically seeds with synthetic questions generated from:
</p>
<ul className="list-disc list-inside space-y-2 text-foreground-secondary">
@ -424,8 +422,8 @@ const followUp = await askSkills(
</ul>
<p className="text-foreground-secondary mt-4">
This ensures the endpoint is useful immediately, even before any real agent interactions.
Seeding typically adds 10-15 synthetic Q&amp;A pairs.
This ensures the endpoint is useful immediately, even before any real agent
interactions. Seeding typically adds 10-15 synthetic Q&amp;A pairs.
</p>
<div className="bg-background-secondary p-4 rounded-lg mt-4">
@ -490,7 +488,10 @@ const followUp = await askSkills(
</p>
</li>
<li>
<Link href="/docs/api/collections" className="text-primary hover:underline font-medium">
<Link
href="/docs/api/collections"
className="text-primary hover:underline font-medium"
>
Collections API
</Link>
<p className="text-foreground-secondary text-sm">

View file

@ -1,15 +1,10 @@
'use client';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@tpmjs/ui/Card/Card';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Skeleton } from '@tpmjs/ui/Skeleton/Skeleton';
// Simple relative time formatter
function formatRelativeTime(date: Date): string {
const now = Date.now();
@ -24,6 +19,7 @@ function formatRelativeTime(date: Date): string {
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}
import { useEffect, useState } from 'react';
interface SkillQuestion {
@ -104,11 +100,7 @@ export function SkillsActivityFeed({
return (
<Card variant="default" className="border-dashed">
<CardContent padding="lg" className="text-center">
<Icon
icon="message"
size="lg"
className="mx-auto text-foreground-tertiary mb-2"
/>
<Icon icon="message" size="lg" className="mx-auto text-foreground-tertiary mb-2" />
<p className="text-foreground-secondary text-sm">
No questions yet. Be the first to ask!
</p>
@ -127,10 +119,7 @@ export function SkillsActivityFeed({
{q.question}
</CardTitle>
<div className="flex items-center gap-1 flex-shrink-0">
<Badge
variant={q.confidence >= 0.7 ? 'success' : 'secondary'}
size="sm"
>
<Badge variant={q.confidence >= 0.7 ? 'success' : 'secondary'} size="sm">
{Math.round(q.confidence * 100)}%
</Badge>
</div>

View file

@ -67,11 +67,7 @@ curl -X POST "${skillsUrl}" \\
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowApiDocs(!showApiDocs)}
>
<Button variant="ghost" size="sm" onClick={() => setShowApiDocs(!showApiDocs)}>
<Icon icon="terminal" className="w-4 h-4 mr-1" />
API
</Button>
@ -122,9 +118,7 @@ curl -X POST "${skillsUrl}" \\
{/* Activity Feed Column */}
<div className="md:col-span-2">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-foreground-secondary">
Recent Questions
</h3>
<h3 className="text-sm font-medium text-foreground-secondary">Recent Questions</h3>
</div>
<SkillsActivityFeed collectionId={collectionId} limit={5} />
</div>
@ -144,11 +138,7 @@ curl -X POST "${skillsUrl}" \\
Every question helps improve future responses for all agents.
</p>
</div>
<Button
variant="default"
size="sm"
onClick={() => setShowApiDocs(!showApiDocs)}
>
<Button variant="default" size="sm" onClick={() => setShowApiDocs(!showApiDocs)}>
Get Started
</Button>
</div>

View file

@ -1,12 +1,7 @@
'use client';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@tpmjs/ui/Card/Card';
import { Card, CardContent, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Skeleton } from '@tpmjs/ui/Skeleton/Skeleton';
import { useEffect, useState } from 'react';
@ -25,9 +20,7 @@ interface SkillsStatsProps {
collectionId: string;
}
export function SkillsStats({
collectionId,
}: SkillsStatsProps): React.ReactElement | null {
export function SkillsStats({ collectionId }: SkillsStatsProps): React.ReactElement | null {
const [stats, setStats] = useState<SkillStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -35,9 +28,7 @@ export function SkillsStats({
useEffect(() => {
async function fetchStats() {
try {
const response = await fetch(
`/api/skills/stats?collectionId=${collectionId}`
);
const response = await fetch(`/api/skills/stats?collectionId=${collectionId}`);
if (!response.ok) {
throw new Error('Failed to fetch stats');
}

View file

@ -30,9 +30,7 @@ export async function embedQuestion(text: string): Promise<number[]> {
*/
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error(
`Vector dimension mismatch: ${a.length} vs ${b.length}`
);
throw new Error(`Vector dimension mismatch: ${a.length} vs ${b.length}`);
}
let dotProduct = 0;
@ -73,11 +71,7 @@ export async function findSimilarQuestions(
excludeId?: string;
} = {}
): Promise<SimilarQuestion[]> {
const {
threshold = DEFAULT_SIMILARITY_THRESHOLD,
limit = 5,
excludeId,
} = options;
const { threshold = DEFAULT_SIMILARITY_THRESHOLD, limit = 5, excludeId } = options;
// Fetch all questions for this collection
const questions = await prisma.skillQuestion.findMany({
@ -115,9 +109,7 @@ export async function findSimilarQuestions(
}
// Sort by similarity descending and limit results
return similar
.sort((a, b) => b.similarity - a.similarity)
.slice(0, limit);
return similar.sort((a, b) => b.similarity - a.similarity).slice(0, limit);
}
/**
@ -191,11 +183,10 @@ export async function checkQuestionSimilarity(
}
// Find similar questions for RAG context
const similarQuestions = await findSimilarQuestions(
embedding,
collectionId,
{ threshold: DEFAULT_SIMILARITY_THRESHOLD, limit: 5 }
);
const similarQuestions = await findSimilarQuestions(embedding, collectionId, {
threshold: DEFAULT_SIMILARITY_THRESHOLD,
limit: 5,
});
return {
isCacheHit: false,

View file

@ -13,10 +13,7 @@ import type { Skill, Tool } from '@prisma/client';
import { prisma } from '@tpmjs/db';
import { generateObject } from 'ai';
import { z } from 'zod';
import {
cosineSimilarity,
embedQuestion,
} from './skills-embedding';
import { cosineSimilarity, embedQuestion } from './skills-embedding';
const SKILL_MATCH_THRESHOLD = 0.75;
@ -50,12 +47,8 @@ export async function extractSkillsFromQuestion(
schema: z.object({
skills: z.array(
z.object({
name: z
.string()
.describe('Short skill name (2-5 words), e.g., "API error handling"'),
description: z
.string()
.describe('One sentence describing what this skill enables'),
name: z.string().describe('Short skill name (2-5 words), e.g., "API error handling"'),
description: z.string().describe('One sentence describing what this skill enables'),
})
),
}),
@ -228,11 +221,7 @@ export async function updateSkillGraph(params: {
// 2. Get or create skill nodes and link to question
for (const extracted of extractedSkills) {
try {
const skill = await getOrCreateSkill(
collectionId,
extracted.name,
extracted.description
);
const skill = await getOrCreateSkill(collectionId, extracted.name, extracted.description);
// Link question to skill
await prisma.skillQuestionSkill.upsert({
@ -314,9 +303,7 @@ export async function updateSkillGraph(params: {
/**
* Get skill summary for a collection
*/
export async function getCollectionSkillsSummary(
collectionId: string
): Promise<{
export async function getCollectionSkillsSummary(collectionId: string): Promise<{
totalQuestions: number;
totalSkills: number;
topSkills: Array<{
@ -351,9 +338,7 @@ export async function getCollectionSkillsSummary(
* Recalculate confidence scores for all skills in a collection
* (Useful for batch updates or maintenance)
*/
export async function recalculateSkillConfidence(
collectionId: string
): Promise<void> {
export async function recalculateSkillConfidence(collectionId: string): Promise<void> {
const skills = await prisma.skill.findMany({
where: { collectionId },
include: {

View file

@ -65,9 +65,7 @@ function buildSystemPrompt(params: GenerateResponseParams): string {
// Optional tag hints
const tagHints =
tags && tags.length > 0
? `\nThe user has tagged this question with: ${tags.join(', ')}`
: '';
tags && tags.length > 0 ? `\nThe user has tagged this question with: ${tags.join(', ')}` : '';
return `You are a helpful assistant that answers questions about using the tools in the "${collection.name}" collection.
@ -202,8 +200,7 @@ export function calculateConfidence(
// Boost for similar questions (RAG context)
if (similarQuestions.length > 0) {
const avgSimilarity =
similarQuestions.reduce((sum, q) => sum + q.similarity, 0) /
similarQuestions.length;
similarQuestions.reduce((sum, q) => sum + q.similarity, 0) / similarQuestions.length;
confidence += avgSimilarity * 0.4; // Up to 0.4 boost
}

View file

@ -14,11 +14,8 @@ import { prisma } from '@tpmjs/db';
import { generateObject } from 'ai';
import { z } from 'zod';
import { embedQuestion } from './skills-embedding';
import {
generateSkillResponse,
type CollectionContext,
} from './skills-response-generator';
import { updateSkillGraph } from './skills-graph-updater';
import { type CollectionContext, generateSkillResponse } from './skills-response-generator';
const SEED_BATCH_SIZE = 5;
const SEEDING_LOCK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
@ -151,9 +148,7 @@ async function isSeeding(collectionId: string): Promise<boolean> {
* Seed a collection with synthetic questions
* Returns true if seeding was performed, false if skipped
*/
export async function seedCollectionSkills(
collection: CollectionWithTools
): Promise<{
export async function seedCollectionSkills(collection: CollectionWithTools): Promise<{
seeded: boolean;
questionsCreated: number;
reason?: string;
@ -189,10 +184,7 @@ export async function seedCollectionSkills(
// 2. Generate from tool descriptions
if (collection.tools.length > 0) {
const toolQuestions = await generateQuestionsFromTools(
collection.tools,
5
);
const toolQuestions = await generateQuestionsFromTools(collection.tools, 5);
allQuestions.push(...toolQuestions);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff