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/README.md +0 -20
- package/package.json +1 -1
- package/src/browser.js +52 -2
- package/src/cli.js +7 -6
- package/src/renderer.js +2 -40
- package/src/server.js +1 -2
- package/.env.example +0 -25
- package/src/apply.js +0 -745
- package/src/dashboard.js +0 -196
- package/src/llm.js +0 -220
- package/src/pipeline.js +0 -317
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 };
|