textweb 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dashboard.js DELETED
@@ -1,196 +0,0 @@
1
- /**
2
- * TextWeb Job Pipeline Dashboard
3
- *
4
- * Manages job tracking state and pushes live updates to OpenClaw Canvas.
5
- * Uses a local JSON file for persistence and canvas.eval for real-time UI.
6
- */
7
-
8
- const fs = require('fs');
9
- const path = require('path');
10
- const http = require('http');
11
-
12
- const STATE_FILE = path.join(process.env.HOME, '.jobsearch', 'pipeline-state.json');
13
-
14
- // ── State Management ──────────────────────────────────────────
15
-
16
- function loadState() {
17
- try {
18
- return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
19
- } catch {
20
- return { jobs: [], stats: { totalApplied: 0, sessions: [] }, lastUpdated: null };
21
- }
22
- }
23
-
24
- function saveState(state) {
25
- state.lastUpdated = new Date().toISOString();
26
- fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
27
- fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
28
- }
29
-
30
- function addJob(job) {
31
- const state = loadState();
32
- // Dedup by company+title
33
- const existing = state.jobs.findIndex(j => j.company === job.company && j.title === job.title);
34
- if (existing >= 0) {
35
- state.jobs[existing] = { ...state.jobs[existing], ...job };
36
- } else {
37
- state.jobs.push(job);
38
- }
39
- saveState(state);
40
- return state;
41
- }
42
-
43
- function updateJobStatus(company, title, status, extra = {}) {
44
- const state = loadState();
45
- const job = state.jobs.find(j => j.company === company && j.title === title);
46
- if (job) {
47
- job.status = status;
48
- Object.assign(job, extra);
49
- saveState(state);
50
- }
51
- return state;
52
- }
53
-
54
- function getStats(state) {
55
- state = state || loadState();
56
- const confirmed = state.jobs.filter(j => j.status === 'submitted').length;
57
- const probable = state.jobs.filter(j => j.status === 'probable').length;
58
- const failed = state.jobs.filter(j => j.status === 'failed').length;
59
- const active = state.jobs.filter(j => j.status === 'active').length;
60
- const total = state.jobs.length;
61
- return { confirmed, probable, failed, active, total };
62
- }
63
-
64
- // ── Canvas Push ───────────────────────────────────────────────
65
-
66
- // Canvas session dir — OpenClaw uses agent_main_main for main session
67
- function getCanvasDir() {
68
- const base = path.join(process.env.HOME, 'Library', 'Application Support', 'OpenClaw', 'canvas');
69
- // Try agent_main_main first, fall back to main
70
- const agent = path.join(base, 'agent_main_main');
71
- if (fs.existsSync(agent)) return agent;
72
- const main = path.join(base, 'main');
73
- fs.mkdirSync(main, { recursive: true });
74
- return main;
75
- }
76
-
77
- // Write dashboard HTML with embedded state data
78
- function pushCanvasState(state, liveStatus) {
79
- const canvasDir = getCanvasDir();
80
- const payload = {
81
- ...state,
82
- liveStatus: liveStatus || null,
83
- pushedAt: new Date().toISOString(),
84
- };
85
-
86
- // Read template and inject state
87
- const templatePath = path.join(__dirname, '..', 'canvas', 'dashboard.html');
88
- let html;
89
- if (fs.existsSync(templatePath)) {
90
- html = fs.readFileSync(templatePath, 'utf8');
91
- } else {
92
- html = getDashboardHTML();
93
- }
94
-
95
- // Inject state as embedded JSON
96
- const stateScript = `<script>window.__PIPELINE_STATE__ = ${JSON.stringify(payload)};</script>`;
97
- html = html.replace('</head>', stateScript + '\n</head>');
98
-
99
- fs.writeFileSync(path.join(canvasDir, 'dashboard.html'), html);
100
- }
101
-
102
- // Push state to canvas by writing HTML with embedded state
103
- function pushFullDashboard(state) {
104
- state = state || loadState();
105
- pushCanvasState(state);
106
- }
107
-
108
- function pushStatusUpdate(company, title, status, detail) {
109
- const state = loadState();
110
- pushCanvasState(state, detail);
111
- }
112
-
113
- function getDashboardHTML() {
114
- // Inline fallback if template not found
115
- return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Pipeline</title></head><body style="background:#0a0a1a;color:#eee;font-family:system-ui;padding:20px"><h1>TextWeb Pipeline</h1><div id="data"></div><script>const s=window.__PIPELINE_STATE__||{jobs:[]};document.getElementById("data").textContent=JSON.stringify(s,null,2);<\/script></body></html>';
116
- }
117
-
118
- // ── Event Emitter for Pipeline Integration ────────────────────
119
-
120
- class PipelineDashboard {
121
- constructor() {
122
- this.state = loadState();
123
- }
124
-
125
- async init() {
126
- await pushFullDashboard(this.state);
127
- }
128
-
129
- async queueJob(job) {
130
- this.state = addJob({
131
- ...job,
132
- status: 'queued',
133
- queuedAt: new Date().toISOString(),
134
- });
135
- await pushFullDashboard(this.state);
136
- }
137
-
138
- async startJob(company, title) {
139
- this.state = updateJobStatus(company, title, 'active', { startedAt: new Date().toISOString() });
140
- await pushFullDashboard(this.state);
141
- }
142
-
143
- async fieldFilling(company, title, fieldName, value) {
144
- await pushStatusUpdate(company, title, 'filling', `${company}: ✏️ ${fieldName} → ${value.substring(0, 30)}`);
145
- }
146
-
147
- async llmGenerating(company, title, count) {
148
- await pushStatusUpdate(company, title, 'llm', `${company}: 🤖 Generating ${count} answers...`);
149
- }
150
-
151
- async uploading(company, title) {
152
- await pushStatusUpdate(company, title, 'uploading', `${company}: 📎 Uploading resume...`);
153
- }
154
-
155
- async submitting(company, title) {
156
- await pushStatusUpdate(company, title, 'submitting', `${company}: 🔘 Submitting...`);
157
- }
158
-
159
- async submitted(company, title, details = {}) {
160
- this.state = updateJobStatus(company, title, 'submitted', {
161
- submittedAt: new Date().toISOString(),
162
- ...details,
163
- });
164
- await pushFullDashboard(this.state);
165
- }
166
-
167
- async probable(company, title, details = {}) {
168
- this.state = updateJobStatus(company, title, 'probable', {
169
- submittedAt: new Date().toISOString(),
170
- ...details,
171
- });
172
- await pushFullDashboard(this.state);
173
- }
174
-
175
- async failed(company, title, reason) {
176
- this.state = updateJobStatus(company, title, 'failed', {
177
- failedAt: new Date().toISOString(),
178
- failReason: reason,
179
- });
180
- await pushFullDashboard(this.state);
181
- }
182
-
183
- getState() { return loadState(); }
184
- getStats() { return getStats(loadState()); }
185
- }
186
-
187
- module.exports = {
188
- PipelineDashboard,
189
- loadState,
190
- saveState,
191
- addJob,
192
- updateJobStatus,
193
- getStats,
194
- pushFullDashboard,
195
- pushStatusUpdate,
196
- };
package/src/llm.js DELETED
@@ -1,220 +0,0 @@
1
- /**
2
- * TextWeb LLM Integration
3
- *
4
- * Generates answers for freeform job application questions using
5
- * a local LLM (LM Studio) or remote API (OpenAI-compatible).
6
- *
7
- * Configuration (in order of priority):
8
- * 1. Config object passed to functions
9
- * 2. Environment variables (TEXTWEB_LLM_URL, TEXTWEB_LLM_API_KEY, etc.)
10
- * 3. .env file in project root or cwd
11
- * 4. Defaults (LM Studio at localhost:1234)
12
- *
13
- * Environment variables:
14
- * TEXTWEB_LLM_URL - Base URL for OpenAI-compatible API (default: http://localhost:1234/v1)
15
- * TEXTWEB_LLM_API_KEY - API key (optional for local LLMs)
16
- * TEXTWEB_LLM_MODEL - Model name (default: google/gemma-3-4b)
17
- * TEXTWEB_LLM_MAX_TOKENS - Max tokens (default: 200)
18
- * TEXTWEB_LLM_TEMPERATURE - Temperature (default: 0.7)
19
- * TEXTWEB_LLM_TIMEOUT - Request timeout in ms (default: 60000)
20
- */
21
-
22
- const http = require('http');
23
- const https = require('https');
24
- const fs = require('fs');
25
- const path = require('path');
26
-
27
- // Load .env file if present (lightweight, no dependency)
28
- function loadEnv() {
29
- const candidates = [
30
- path.join(process.cwd(), '.env'),
31
- path.join(__dirname, '..', '.env'),
32
- ];
33
- for (const envPath of candidates) {
34
- try {
35
- const content = fs.readFileSync(envPath, 'utf8');
36
- for (const line of content.split('\n')) {
37
- const trimmed = line.trim();
38
- if (!trimmed || trimmed.startsWith('#')) continue;
39
- const eqIdx = trimmed.indexOf('=');
40
- if (eqIdx === -1) continue;
41
- const key = trimmed.slice(0, eqIdx).trim();
42
- let val = trimmed.slice(eqIdx + 1).trim();
43
- // Strip quotes
44
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
45
- val = val.slice(1, -1);
46
- }
47
- if (!process.env[key]) process.env[key] = val;
48
- }
49
- break; // only load first found
50
- } catch (_) { /* no .env, that's fine */ }
51
- }
52
- }
53
- loadEnv();
54
-
55
- const DEFAULT_CONFIG = {
56
- baseUrl: process.env.TEXTWEB_LLM_URL || 'http://localhost:1234/v1',
57
- apiKey: process.env.TEXTWEB_LLM_API_KEY || '',
58
- model: process.env.TEXTWEB_LLM_MODEL || 'google/gemma-3-4b',
59
- maxTokens: parseInt(process.env.TEXTWEB_LLM_MAX_TOKENS, 10) || 200,
60
- temperature: parseFloat(process.env.TEXTWEB_LLM_TEMPERATURE) || 0.7,
61
- timeout: parseInt(process.env.TEXTWEB_LLM_TIMEOUT, 10) || 60000,
62
- };
63
-
64
- // ─── Applicant Background (for prompt context) ─────────────────────────────
65
-
66
- const BACKGROUND = `Christopher Robison — Engineering leader with 25+ years of experience.
67
-
68
- Current: CTO at D. Harris Tours (transportation management platform, grew fleet from 4 to 16 buses, ~$3.7M revenue)
69
-
70
- Key Experience:
71
- - Food.com (1998-2000): Web Architect — built world's first online food ordering service
72
- - Genetic Savings & Clone (2004-2006): VP Engineering — delivered commercially cloned pets
73
- - Mindjet (2007-2010): Web Architect — led SaaS transformation of MindManager
74
- - Conversant/ValueClick (2010-2020): Manager, Software Engineering — mobile ad platform serving 20M+ users/day
75
- - D. Harris Tours (2020-present): CTO — end-to-end transportation management system
76
-
77
- Skills: Python, JavaScript, TypeScript, Swift, Kotlin, Rust, Go, C/C++, React, Node.js, AWS, GCP, Docker, Kubernetes, PostgreSQL, MongoDB, Redis. iOS/Android mobile. Infrastructure at scale.
78
-
79
- Leadership: Built and managed engineering teams of 5-30. Hired, mentored, promoted. Player-coach who codes daily. Agile/Scrum, CI/CD, platform architecture.
80
-
81
- Location: San Francisco, CA. Open to remote.
82
- Available: Immediately.`;
83
-
84
- // ─── Answer Generation ──────────────────────────────────────────────────────
85
-
86
- /**
87
- * Generate an answer for a freeform application question
88
- *
89
- * @param {string} question - The question text from the form
90
- * @param {string} jobDescription - The job posting text (optional)
91
- * @param {string} company - Company name (optional)
92
- * @param {object} config - LLM config overrides
93
- * @returns {string} Generated answer
94
- */
95
- async function generateAnswer(question, jobDescription, company, config = {}) {
96
- const cfg = { ...DEFAULT_CONFIG, ...config };
97
-
98
- const systemPrompt = `You are filling out a job application for ${company || 'a company'}. Write a concise, authentic answer to the application question.
99
-
100
- Rules:
101
- - Be specific and genuine, not generic
102
- - Reference real experience from the background provided
103
- - Keep answers 1-3 sentences for short questions, 1-2 paragraphs for essay questions
104
- - Don't be sycophantic or desperate — be confident and direct
105
- - Match the tone to the question (casual if casual, professional if professional)
106
- - For yes/no questions, answer Yes or No then briefly explain if relevant
107
- - For "anything else" or "additional info" questions, keep it brief or say "Nothing additional at this time."`;
108
-
109
- const userPrompt = `APPLICANT BACKGROUND:
110
- ${BACKGROUND}
111
-
112
- ${jobDescription ? `JOB DESCRIPTION:\n${jobDescription.substring(0, 2000)}\n` : ''}
113
- APPLICATION QUESTION: "${question}"
114
-
115
- Write the answer (just the answer text, no preamble):`;
116
-
117
- const body = JSON.stringify({
118
- model: cfg.model,
119
- messages: [
120
- { role: 'system', content: systemPrompt },
121
- { role: 'user', content: userPrompt },
122
- ],
123
- max_tokens: cfg.maxTokens,
124
- temperature: cfg.temperature,
125
- stream: false,
126
- });
127
-
128
- return new Promise((resolve, reject) => {
129
- const url = new URL(cfg.baseUrl + '/chat/completions');
130
- const transport = url.protocol === 'https:' ? https : http;
131
-
132
- const req = transport.request({
133
- hostname: url.hostname,
134
- port: url.port,
135
- path: url.pathname,
136
- method: 'POST',
137
- headers: {
138
- 'Content-Type': 'application/json',
139
- 'Content-Length': Buffer.byteLength(body),
140
- ...(cfg.apiKey ? { 'Authorization': `Bearer ${cfg.apiKey}` } : {}),
141
- },
142
- timeout: cfg.timeout || 60000,
143
- }, (res) => {
144
- let data = '';
145
- res.on('data', chunk => data += chunk);
146
- res.on('end', () => {
147
- try {
148
- const json = JSON.parse(data);
149
- const answer = json.choices?.[0]?.message?.content?.trim();
150
- if (!answer) {
151
- reject(new Error('Empty response from LLM'));
152
- return;
153
- }
154
- resolve(answer);
155
- } catch (e) {
156
- reject(new Error(`LLM parse error: ${e.message}`));
157
- }
158
- });
159
- });
160
-
161
- req.on('error', (e) => reject(new Error(`LLM request failed: ${e.message}`)));
162
- req.on('timeout', () => { req.destroy(); reject(new Error('LLM request timed out')); });
163
- req.write(body);
164
- req.end();
165
- });
166
- }
167
-
168
- /**
169
- * Batch-generate answers for multiple unknown fields
170
- *
171
- * @param {Array} unknownFields - Array of { ref, field } objects
172
- * @param {string} jobDescription - Job posting text
173
- * @param {string} company - Company name
174
- * @param {object} config - LLM config
175
- * @returns {Object} Map of ref → answer
176
- */
177
- async function generateAnswers(unknownFields, jobDescription, company, config = {}) {
178
- const answers = {};
179
-
180
- for (const field of unknownFields) {
181
- try {
182
- const answer = await generateAnswer(field.field, jobDescription, company, config);
183
- answers[field.ref] = answer;
184
- } catch (err) {
185
- console.error(` ⚠️ Failed to generate answer for "${field.field}": ${err.message}`);
186
- answers[field.ref] = null;
187
- }
188
- }
189
-
190
- return answers;
191
- }
192
-
193
- /**
194
- * Check if LLM is available
195
- */
196
- async function checkLLM(config = {}) {
197
- const cfg = { ...DEFAULT_CONFIG, ...config };
198
- return new Promise((resolve) => {
199
- const url = new URL(cfg.baseUrl + '/models');
200
- const transport = url.protocol === 'https:' ? https : http;
201
-
202
- const req = transport.request({
203
- hostname: url.hostname,
204
- port: url.port,
205
- path: url.pathname,
206
- method: 'GET',
207
- timeout: 3000,
208
- }, (res) => {
209
- let data = '';
210
- res.on('data', chunk => data += chunk);
211
- res.on('end', () => resolve(true));
212
- });
213
-
214
- req.on('error', () => resolve(false));
215
- req.on('timeout', () => { req.destroy(); resolve(false); });
216
- req.end();
217
- });
218
- }
219
-
220
- module.exports = { generateAnswer, generateAnswers, checkLLM, BACKGROUND, DEFAULT_CONFIG };