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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![Release](https://github.com/sir-ad/knight/actions/workflows/release.yml/badge.svg)](https://github.com/sir-ad/knight/actions/workflows/release.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-0f172a.svg)](./LICENSE)

Privacy-first Chrome extension for job applications. Knight parses your resume locally with Ollama, autofills common ATS portals, logs applications, syncs Gmail status updates, and drafts follow-ups without sending your profile to a hosted LLM.
Privacy-first Chrome extension for job applications. Knight parses your resume locally or through your chosen provider, autofills common ATS portals, logs applications, syncs Gmail status updates, and drafts follow-ups without forcing your profile through a hosted AI workflow.

![Knight icon](./careerflow/assets/icon128.png)

Expand All @@ -14,11 +14,13 @@ Website: [sir-ad.github.io/knight](https://sir-ad.github.io/knight/)
## What It Does

- Local-first resume parsing for `PDF`, `DOCX`, and `TXT`
- AI provider support for `Ollama`, `OpenAI`, `Anthropic`, `Google Gemini`, and `OpenRouter`
- Smart defaults that auto-discover installed Ollama models and recommend the best reachable provider/model
- Autofill support for `Workday`, `Greenhouse`, `Lever`, `Naukri`, `iCIMS`, `SmartRecruiters`, `Taleo`, `SuccessFactors`, plus a generic fallback
- In-page review overlay before filling
- Local application log with status history
- Gmail read-only sync for confirmations, interviews, rejections, and offers
- Follow-up email draft generation with Ollama
- Follow-up email draft generation with the active provider

## Repo Layout

Expand All @@ -40,15 +42,30 @@ Then load `careerflow/build/chrome-mv3-prod` in `chrome://extensions` with Devel
## Local Requirements

- Node.js 20+
- Ollama running locally
- Ollama running locally for the privacy-first path
- A pulled model such as `llama3.2:3b`
- Optional cloud provider API key for `OpenAI`, `Anthropic`, `Google Gemini`, or `OpenRouter`
- Optional Gmail OAuth setup using `PLASMO_PUBLIC_GOOGLE_CLIENT_ID`

```bash
ollama serve
OLLAMA_ORIGINS=chrome-extension://* ollama serve
ollama pull llama3.2:3b
```

Knight expects the Ollama endpoint as the host root only, for example `http://localhost:11434`. Do not add `/api` to the saved endpoint.

## Supported Portals

- [Workday](https://www.workday.com/) via `*.myworkdayjobs.com`
- [Greenhouse](https://www.greenhouse.com/) via `boards.greenhouse.io` and `*.greenhouse.io`
- [Lever](https://www.lever.co/) via `jobs.lever.co` and `*.lever.co`
- [Naukri](https://www.naukri.com/) via `*.naukri.com`
- [iCIMS](https://www.icims.com/) via `*.icims.com`
- [SmartRecruiters](https://www.smartrecruiters.com/) via `*.smartrecruiters.com`
- [Oracle Taleo](https://www.oracle.com/human-capital-management/taleo/) via `*.taleo.net`
- [SAP SuccessFactors](https://www.sap.com/products/hcm.html) via `*successfactors*` and `*.jobs2web.com`
- Generic fallback for employer-hosted forms that expose standard labels, placeholders, or aria metadata

## Validation

From [`careerflow`](./careerflow):
Expand All @@ -68,7 +85,7 @@ npm run package

## Tags

`chrome-extension` `job-search` `ats-autofill` `ollama` `gmail` `privacy-first` `plasmo` `typescript`
`chrome-extension` `job-search` `ats-autofill` `ollama` `openai` `anthropic` `gemini` `openrouter` `gmail` `privacy-first` `plasmo` `typescript`

## Notes

Expand Down
33 changes: 21 additions & 12 deletions careerflow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,26 @@

Chrome MV3 extension for local-first job application automation.

Knight is built with Plasmo and centered on one constraint: resume data should stay on the user’s machine. The extension parses resumes with Ollama, injects a review-first autofill overlay on common ATS sites, logs applications locally, reads Gmail with a read-only scope for status changes, and drafts follow-ups from local context.
Knight is built with Plasmo and centered on one constraint: resume data should stay on the user’s machine whenever possible. The extension parses resumes with a configurable provider stack, injects a review-first autofill overlay on common ATS sites, logs applications locally, reads Gmail with a read-only scope for status changes, and drafts follow-ups from local context.

## Current Scope

- Resume parsing for `PDF`, `DOCX`, and `TXT`
- AI provider support for `Ollama`, `OpenAI`, `Anthropic`, `Google Gemini`, and `OpenRouter`
- Smart defaults that auto-discover installed Ollama models and recommend the best reachable provider/model
- Autofill support for:
- `Workday`
- `Greenhouse`
- `Lever`
- `Naukri`
- `iCIMS`
- `SmartRecruiters`
- `Taleo`
- `SuccessFactors`
- `Workday` via `*.myworkdayjobs.com`
- `Greenhouse` via `boards.greenhouse.io` and `*.greenhouse.io`
- `Lever` via `jobs.lever.co` and `*.lever.co`
- `Naukri` via `*.naukri.com`
- `iCIMS` via `*.icims.com`
- `SmartRecruiters` via `*.smartrecruiters.com`
- `Taleo` via `*.taleo.net`
- `SuccessFactors` / `Jobs2Web`
- Generic fallback forms
- Local application tracking with status history
- Gmail read-only sync for confirmation, interview, rejection, and offer emails
- Ollama-based follow-up drafting
- Follow-up drafting with the active provider

## Stack

Expand All @@ -28,17 +30,23 @@ Knight is built with Plasmo and centered on one constraint: resume data should s
- `React 18`
- `Tailwind CSS`
- `Ollama`
- `OpenAI`
- `Anthropic`
- `Google Gemini`
- `OpenRouter`
- `Jest`

## Local Setup

```bash
cd careerflow
npm install
ollama serve
OLLAMA_ORIGINS=chrome-extension://* ollama serve
ollama pull llama3.2:3b
```

Knight expects the Ollama endpoint as the host root only, for example `http://localhost:11434`. Do not save `/api`, `/api/tags`, `/api/generate`, or `/api/chat` in Settings.

Optional environment variable:

```bash
Expand Down Expand Up @@ -82,4 +90,5 @@ npm run package

- The Gmail flow uses Chrome Identity with token-based OAuth and a read-only Gmail scope.
- Local storage migration handles prior `knight_*` keys.
- The extension defaults to the configured Ollama endpoint and model from Settings.
- The extension keeps provider API keys in local Chrome storage and excludes them from export.
- Smart defaults prefer a reachable local Ollama model, then fall back to the best configured cloud provider.
8 changes: 6 additions & 2 deletions careerflow/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "knight-extension",
"displayName": "Knight",
"version": "0.1.2",
"version": "0.2.0",
"description": "Knight - Universal Job Application & Tracking System - Privacy-first browser extension",
"license": "MIT",
"author": "sir-ad",
Expand Down Expand Up @@ -68,9 +68,13 @@
},
"name": "Knight",
"description": "Knight - Universal Job Application & Tracking System",
"version": "0.1.2",
"version": "0.2.0",
"host_permissions": [
"https://www.googleapis.com/*",
"https://api.openai.com/*",
"https://api.anthropic.com/*",
"https://generativelanguage.googleapis.com/*",
"https://openrouter.ai/*",
"http://localhost:11434/*",
"http://127.0.0.1:11434/*"
],
Expand Down
154 changes: 153 additions & 1 deletion careerflow/src/background/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,105 @@ import { gmailClient } from "./gmail-client"
import { ApplicationTracker } from "../lib/application-tracker"
import { classifyEmail } from "../lib/email-classifier"
import * as db from "../lib/db"
import { storageManager } from "../lib/storage-manager"
import { storageManager, normalizeProviderSettings } from "../lib/storage-manager"
import {
buildLLMConfig,
diagnoseProvider,
discoverProviderModels,
generateProviderChat,
generateProviderStructured,
generateProviderText,
providerSupportsApiKey,
recommendLLMConfiguration,
} from "../lib/llm/provider-service"
import { SUPPORTED_PORTALS } from "../lib/supported-portals"
import type {
ApplicationLogPayload,
ApplicationStatus,
ATSAdapterName,
GmailStatus,
ProviderModelCatalog,
ProviderSecretStore,
ProviderSettings,
RuntimeMessage,
RuntimeResponse,
} from "../lib/types"
import type { LLMConfig } from "../lib/llm/types"

const applicationTracker = new ApplicationTracker()

async function resolveLLMRequest(payload?: {
config?: Partial<LLMConfig>
apiKey?: string
}): Promise<{
config: LLMConfig
secrets: ProviderSecretStore
settings: Awaited<ReturnType<typeof storageManager.getSettings>>
providerSettings: ProviderSettings
}> {
const [settings, storedSecrets] = await Promise.all([
storageManager.getSettings(),
storageManager.getProviderSecrets(),
])

const providerSettings = normalizeProviderSettings({
...(settings.llmConfig as ProviderSettings),
...(payload?.config || {}),
})

const secrets = { ...storedSecrets }
if (providerSupportsApiKey(providerSettings.provider) && payload?.apiKey) {
secrets[providerSettings.provider] = payload.apiKey.trim()
}

return {
config: buildLLMConfig(providerSettings, secrets, payload?.config),
secrets,
settings,
providerSettings,
}
}

async function persistCatalogAndRecommendation(
catalog: ProviderModelCatalog,
settings: Awaited<ReturnType<typeof storageManager.getSettings>>,
secrets: ProviderSecretStore,
providerSettings: ProviderSettings
) {
const nextCatalogs = {
...settings.providerCatalogs,
[catalog.provider]: catalog,
}

const nextConfig =
settings.autoMode === "smart-defaults"
? normalizeProviderSettings({
...providerSettings,
model: catalog.recommendedModel,
...(providerSettings.provider === "ollama" && catalog.endpoint
? { endpoint: catalog.endpoint }
: {}),
})
: settings.llmConfig

const recommendation = recommendLLMConfiguration(
{
...settings,
llmConfig: nextConfig,
providerCatalogs: nextCatalogs,
},
secrets
)

await storageManager.saveSettings({
llmConfig: nextConfig,
providerCatalogs: nextCatalogs,
lastRecommendation: recommendation,
})

return recommendation
}

function mapClassificationToStatus(classification: string): ApplicationStatus {
switch (classification) {
case "confirmation":
Expand Down Expand Up @@ -244,6 +331,71 @@ chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResp
} satisfies RuntimeResponse)
break

case "TEST_LLM_PROVIDER": {
const { config } = await resolveLLMRequest(message.payload)
sendResponse({
success: true,
data: await diagnoseProvider(config),
} satisfies RuntimeResponse)
break
}

case "DISCOVER_LLM_MODELS": {
const { config, secrets, settings, providerSettings } = await resolveLLMRequest(
message.payload
)
const catalog = await discoverProviderModels(config)
const recommendation = await persistCatalogAndRecommendation(
catalog,
settings,
secrets,
providerSettings
)

sendResponse({
success: true,
data: {
catalog,
recommendation,
},
} satisfies RuntimeResponse)
break
}

case "GENERATE_LLM_TEXT": {
const { config } = await resolveLLMRequest(message.payload)
sendResponse({
success: true,
data: await generateProviderText(config, message.payload.prompt),
} satisfies RuntimeResponse<string>)
break
}

case "GENERATE_LLM_STRUCTURED": {
const { config } = await resolveLLMRequest(message.payload)
sendResponse({
success: true,
data: await generateProviderStructured(config, message.payload.prompt),
} satisfies RuntimeResponse)
break
}

case "GENERATE_LLM_CHAT": {
const { config } = await resolveLLMRequest(message.payload)
sendResponse({
success: true,
data: await generateProviderChat(config, message.payload.messages),
} satisfies RuntimeResponse<string>)
break
}

case "GET_SUPPORTED_PORTALS":
sendResponse({
success: true,
data: SUPPORTED_PORTALS,
} satisfies RuntimeResponse)
break

default:
sendResponse({
success: false,
Expand Down
13 changes: 11 additions & 2 deletions careerflow/src/lib/email-classifier.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ollamaClient } from "./ollama-client"
import { storageManager } from "./storage-manager"
import { buildLLMConfig, generateProviderStructured } from "./llm/provider-service"

export type EmailClassification =
| "confirmation"
Expand Down Expand Up @@ -216,7 +217,15 @@ export async function classifyEmail(emailContent: {
body: string
}): Promise<ClassificationResult> {
try {
const response = await ollamaClient.generateStructured(
const [settings, secrets] = await Promise.all([
storageManager.getSettings(),
storageManager.getProviderSecrets(),
])
const response = await generateProviderStructured(
buildLLMConfig(settings.llmConfig, secrets, {
temperature: 0.2,
maxTokens: 400,
}),
CLASSIFICATION_PROMPT.replace("{subject}", emailContent.subject)
.replace("{from}", emailContent.from)
.replace("{body}", emailContent.body.substring(0, 4000))
Expand Down
6 changes: 2 additions & 4 deletions careerflow/src/lib/followup-generator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getLLMClient } from "./llm"
import { generateStructuredWithActiveProvider } from "./runtime-client"
import type { ApplicationRecord, Profile } from "./types"

export interface FollowUpEmail {
Expand Down Expand Up @@ -70,9 +70,7 @@ export async function generateFollowUpEmail(
profile: Profile
): Promise<FollowUpEmail> {
const prompt = buildPrompt(application, profile)
const client = getLLMClient()

const response = await client.generateStructured(prompt, {
const response = await generateStructuredWithActiveProvider<FollowUpEmail>(prompt, {
temperature: 0.5,
maxTokens: 500,
})
Expand Down
4 changes: 4 additions & 0 deletions careerflow/src/lib/llm/anthropic-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ export class AnthropicProvider implements LLMProviderInterface {
}
}

async discoverModels(): Promise<string[]> {
return this.models
}

getModels(): string[] {
return this.models
}
Expand Down
Loading