loopengine 0.1.90 → 0.1.92
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 +17 -0
- package/dist/bin/cli.js +3 -2
- package/dist/core/system-agents/interviewer.d.ts +76 -0
- package/dist/core/system-agents/interviewer.js +233 -0
- package/dist/core/system-agents/templates.d.ts +10 -0
- package/dist/core/system-agents/templates.js +146 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/web/agents-list-page.js +3 -1
- package/dist/web/console-shell.js +3 -1
- package/dist/web/dev-ui-styles.js +3 -0
- package/dist/web/interview-admin.d.ts +96 -0
- package/dist/web/interview-admin.js +258 -0
- package/dist/web/interview-page.d.ts +1 -0
- package/dist/web/interview-page.js +247 -0
- package/dist/web/templates-admin.d.ts +19 -0
- package/dist/web/templates-admin.js +37 -0
- package/dist/web/templates-page.d.ts +1 -0
- package/dist/web/templates-page.js +130 -0
- package/package.json +2 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Message, ModelCall } from '../core/run-agent.js';
|
|
2
|
+
import { type AgentCandidate, type InterviewStage, type InterviewTools } from '../core/system-agents/interviewer.js';
|
|
3
|
+
import type { AgentConfig } from '../core/agent-config.js';
|
|
4
|
+
export declare class InterviewError extends Error {
|
|
5
|
+
}
|
|
6
|
+
export interface Interview {
|
|
7
|
+
id: string;
|
|
8
|
+
startedAt: string;
|
|
9
|
+
updatedAt: string;
|
|
10
|
+
stage: InterviewStage;
|
|
11
|
+
/** What the person sees: their answers and the interviewer's replies. */
|
|
12
|
+
display: {
|
|
13
|
+
role: 'user' | 'assistant';
|
|
14
|
+
text: string;
|
|
15
|
+
}[];
|
|
16
|
+
/** What the model sees, tool calls included. */
|
|
17
|
+
transcript: Message[];
|
|
18
|
+
candidates: AgentCandidate[];
|
|
19
|
+
/** Candidate index -> the agent created from it. */
|
|
20
|
+
created: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
export declare function setInterviewProject(projectDir: string): void;
|
|
23
|
+
export declare function getInterview(id: string): Interview;
|
|
24
|
+
/** Newest first, without the transcripts. */
|
|
25
|
+
export declare function listInterviews(): Omit<Interview, 'transcript' | 'display'>[];
|
|
26
|
+
export declare function startInterview(): Interview;
|
|
27
|
+
export declare function deleteInterview(id: string): void;
|
|
28
|
+
/** The person's answer, and the interviewer's reply. */
|
|
29
|
+
export declare function answerInterview(id: string, text: unknown, modelCall: ModelCall, tools: InterviewTools): Promise<Interview>;
|
|
30
|
+
/** Ends early: the interviewer ranks what it has. */
|
|
31
|
+
export declare function finishInterview(id: string, modelCall: ModelCall, tools: InterviewTools): Promise<Interview>;
|
|
32
|
+
export interface ChecklistItem {
|
|
33
|
+
label: string;
|
|
34
|
+
/** A console page or tab where it's done. */
|
|
35
|
+
href?: string;
|
|
36
|
+
}
|
|
37
|
+
/** The first free agent name based on the candidate's. */
|
|
38
|
+
export declare function freeAgentName(base: string, exists: (name: string) => boolean): string;
|
|
39
|
+
export interface CreateDeps {
|
|
40
|
+
agentExists(name: string): boolean;
|
|
41
|
+
/** Scaffolds and registers the agent, as Agents → + New does. */
|
|
42
|
+
createAgent(name: string, systemPrompt: string): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
export interface CandidatePreview {
|
|
45
|
+
name: string;
|
|
46
|
+
systemPrompt: string;
|
|
47
|
+
/** What the permission default will be, and why. */
|
|
48
|
+
permissions: string;
|
|
49
|
+
schedule?: string;
|
|
50
|
+
checklist: ChecklistItem[];
|
|
51
|
+
}
|
|
52
|
+
/** What's still needed once the agent exists, each linked to where it's done. */
|
|
53
|
+
export declare function setupChecklist(candidate: AgentCandidate, agentName: string): ChecklistItem[];
|
|
54
|
+
export declare function previewCandidate(id: string, index: number, deps: Pick<CreateDeps, 'agentExists'>): CandidatePreview;
|
|
55
|
+
/** What creating `candidate` would make, under the first free name.
|
|
56
|
+
* Shared with templates (web/templates-admin.ts). */
|
|
57
|
+
export declare function describeCandidate(candidate: AgentCandidate, deps: Pick<CreateDeps, 'agentExists'>): CandidatePreview;
|
|
58
|
+
export interface CreatedAgent {
|
|
59
|
+
name: string;
|
|
60
|
+
checklist: ChecklistItem[];
|
|
61
|
+
exampleMessage?: string;
|
|
62
|
+
/** Set when the schedule couldn't be created; the agent still was. */
|
|
63
|
+
scheduleError?: string;
|
|
64
|
+
}
|
|
65
|
+
/** Creates the agent: unmatched tools ask (a new agent's default is to
|
|
66
|
+
* deny), and a schedule, if any, is created paused until setup is done. */
|
|
67
|
+
export declare function createFromCandidate(id: string, index: number, deps: CreateDeps): Promise<CreatedAgent>;
|
|
68
|
+
/** Creates `candidate` as an agent under the first free name: the agent
|
|
69
|
+
* itself, its permission default switched to "ask", and a paused
|
|
70
|
+
* schedule when it runs on a timer. Shared with templates
|
|
71
|
+
* (web/templates-admin.ts). */
|
|
72
|
+
export declare function createAgentFromCandidate(candidate: AgentCandidate, deps: CreateDeps): Promise<CreatedAgent>;
|
|
73
|
+
/** A registered agent, as the adapter's registry has it. */
|
|
74
|
+
export interface InterviewAgentSource {
|
|
75
|
+
name: string;
|
|
76
|
+
config: AgentConfig;
|
|
77
|
+
createModelCall: () => ModelCall;
|
|
78
|
+
}
|
|
79
|
+
export interface InterviewModel {
|
|
80
|
+
modelCall: ModelCall;
|
|
81
|
+
/** For the agents it creates: the same provider and model. */
|
|
82
|
+
model: {
|
|
83
|
+
provider: string;
|
|
84
|
+
model?: string;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** The interviewer runs on the workspace's own model: borrowed from an
|
|
88
|
+
* agent that has one set, preferring one whose provider key is set.
|
|
89
|
+
* undefined when no agent names a model. */
|
|
90
|
+
export declare function pickInterviewModel(agents: InterviewAgentSource[]): InterviewModel | undefined;
|
|
91
|
+
/** The model a new agent should start on: the same one the interviewer
|
|
92
|
+
* would pick, without building a model call (which throws when its key
|
|
93
|
+
* isn't set). Templates use it. */
|
|
94
|
+
export declare function pickAgentModel(agents: InterviewAgentSource[]): InterviewModel['model'] | undefined;
|
|
95
|
+
/** The interviewer's read-only tools over this workspace. */
|
|
96
|
+
export declare function interviewTools(agents: InterviewAgentSource[]): InterviewTools;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Backs the console's "Find your first agent": saving interviews in the
|
|
2
|
+
// project (.interviews/<id>.json, next to .sessions), running each turn
|
|
3
|
+
// through the interviewer (core/system-agents/interviewer.ts), and
|
|
4
|
+
// turning a chosen candidate into a real agent — the same pieces a
|
|
5
|
+
// person would set up by hand: the agent itself (through the adapter's
|
|
6
|
+
// own create, passed in), its permission default switched to "ask", and
|
|
7
|
+
// a paused schedule when it runs on a timer. Abilities aren't installed
|
|
8
|
+
// here: installing one restarts the server, so they head the Finish
|
|
9
|
+
// setup checklist instead, each one click away.
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { createScheduledTask, pauseScheduledTask } from '../core/scheduler.js';
|
|
14
|
+
import { KeyedMutex } from '../core/session-store.js';
|
|
15
|
+
import { INTERVIEW_OPENING, agentNameFrom, runInterviewTurn, } from '../core/system-agents/interviewer.js';
|
|
16
|
+
import { describeNotifier } from '../core/http-notifier.js';
|
|
17
|
+
import { listInstalledAbilities } from '../bin/ability-manager.js';
|
|
18
|
+
import { searchPublicAbilities } from './abilities-admin.js';
|
|
19
|
+
import { setDefaultDecision } from './actauth-admin.js';
|
|
20
|
+
import { modelKeyEnvVar } from './global-config.js';
|
|
21
|
+
export class InterviewError extends Error {
|
|
22
|
+
}
|
|
23
|
+
// The project the interviews are saved in. The adapter sets it from its
|
|
24
|
+
// own registry's projectDir() (a create-loopengine project's registry is
|
|
25
|
+
// the project's own file, not this package's); the working directory
|
|
26
|
+
// until then.
|
|
27
|
+
let interviewProject = process.cwd();
|
|
28
|
+
export function setInterviewProject(projectDir) {
|
|
29
|
+
interviewProject = projectDir;
|
|
30
|
+
}
|
|
31
|
+
function dir() {
|
|
32
|
+
return join(interviewProject, '.interviews');
|
|
33
|
+
}
|
|
34
|
+
function pathFor(id) {
|
|
35
|
+
if (!/^[a-f0-9-]{8,40}$/.test(id))
|
|
36
|
+
throw new InterviewError('No such interview');
|
|
37
|
+
return join(dir(), `${id}.json`);
|
|
38
|
+
}
|
|
39
|
+
function save(interview) {
|
|
40
|
+
mkdirSync(dir(), { recursive: true });
|
|
41
|
+
interview.updatedAt = new Date().toISOString();
|
|
42
|
+
writeFileSync(pathFor(interview.id), JSON.stringify(interview, null, 2));
|
|
43
|
+
return interview;
|
|
44
|
+
}
|
|
45
|
+
export function getInterview(id) {
|
|
46
|
+
const path = pathFor(id);
|
|
47
|
+
if (!existsSync(path))
|
|
48
|
+
throw new InterviewError('No such interview');
|
|
49
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
50
|
+
}
|
|
51
|
+
/** Newest first, without the transcripts. */
|
|
52
|
+
export function listInterviews() {
|
|
53
|
+
if (!existsSync(dir()))
|
|
54
|
+
return [];
|
|
55
|
+
return readdirSync(dir())
|
|
56
|
+
.filter((f) => f.endsWith('.json'))
|
|
57
|
+
.flatMap((f) => {
|
|
58
|
+
try {
|
|
59
|
+
const { transcript: _t, display: _d, ...rest } = JSON.parse(readFileSync(join(dir(), f), 'utf8'));
|
|
60
|
+
return [rest];
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
67
|
+
}
|
|
68
|
+
export function startInterview() {
|
|
69
|
+
const now = new Date().toISOString();
|
|
70
|
+
return save({ id: randomUUID(), startedAt: now, updatedAt: now, stage: 'work', display: [{ role: 'assistant', text: INTERVIEW_OPENING }], transcript: [], candidates: [], created: {} });
|
|
71
|
+
}
|
|
72
|
+
export function deleteInterview(id) {
|
|
73
|
+
rmSync(pathFor(id), { force: true });
|
|
74
|
+
}
|
|
75
|
+
const locks = new KeyedMutex();
|
|
76
|
+
async function turn(id, userText, shown, modelCall, tools) {
|
|
77
|
+
return locks.run(id, async () => {
|
|
78
|
+
const interview = getInterview(id);
|
|
79
|
+
const result = await runInterviewTurn(modelCall, interview.transcript, userText, tools);
|
|
80
|
+
if (shown !== undefined)
|
|
81
|
+
interview.display.push({ role: 'user', text: shown });
|
|
82
|
+
interview.display.push({ role: 'assistant', text: result.reply });
|
|
83
|
+
interview.transcript.push(...result.newMessages);
|
|
84
|
+
if (result.stage)
|
|
85
|
+
interview.stage = result.stage;
|
|
86
|
+
if (result.candidates) {
|
|
87
|
+
interview.candidates = result.candidates;
|
|
88
|
+
interview.stage = 'done';
|
|
89
|
+
}
|
|
90
|
+
return save(interview);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** The person's answer, and the interviewer's reply. */
|
|
94
|
+
export async function answerInterview(id, text, modelCall, tools) {
|
|
95
|
+
if (typeof text !== 'string' || !text.trim())
|
|
96
|
+
throw new InterviewError('Write an answer first');
|
|
97
|
+
if (text.length > 4000)
|
|
98
|
+
throw new InterviewError('That answer is too long; keep it under 4,000 characters');
|
|
99
|
+
return turn(id, text.trim(), text.trim(), modelCall, tools);
|
|
100
|
+
}
|
|
101
|
+
/** Ends early: the interviewer ranks what it has. */
|
|
102
|
+
export async function finishInterview(id, modelCall, tools) {
|
|
103
|
+
return turn(id, '[The person chose to finish now. Rank what you have so far: call save_candidates with up to three, then set_stage("done"), then tell them briefly.]', undefined, modelCall, tools);
|
|
104
|
+
}
|
|
105
|
+
function candidateAt(interview, index) {
|
|
106
|
+
const candidate = interview.candidates[index];
|
|
107
|
+
if (!candidate)
|
|
108
|
+
throw new InterviewError('No such candidate');
|
|
109
|
+
return candidate;
|
|
110
|
+
}
|
|
111
|
+
/** The first free agent name based on the candidate's. */
|
|
112
|
+
export function freeAgentName(base, exists) {
|
|
113
|
+
const name = agentNameFrom(base);
|
|
114
|
+
if (!exists(name))
|
|
115
|
+
return name;
|
|
116
|
+
for (let n = 2; n < 100; n++)
|
|
117
|
+
if (!exists(`${name}-${n}`))
|
|
118
|
+
return `${name}-${n}`;
|
|
119
|
+
return `${name}-${Date.now()}`;
|
|
120
|
+
}
|
|
121
|
+
/** What's still needed once the agent exists, each linked to where it's done. */
|
|
122
|
+
export function setupChecklist(candidate, agentName) {
|
|
123
|
+
const tab = (t) => `/agents/config?agent=${encodeURIComponent(agentName)}&tab=${t}`;
|
|
124
|
+
const items = candidate.needs.map((need) => {
|
|
125
|
+
switch (need.kind) {
|
|
126
|
+
case 'ability':
|
|
127
|
+
return { label: `Install the ${need.name ?? need.description} ability`, href: tab('abilities') };
|
|
128
|
+
case 'api':
|
|
129
|
+
return { label: `Connect ${need.description} (an HTTP tool, or an MCP server)`, href: tab('tools') };
|
|
130
|
+
case 'key':
|
|
131
|
+
return { label: `Add ${need.description}`, href: tab('env') };
|
|
132
|
+
case 'messaging':
|
|
133
|
+
return { label: `Connect ${need.name ?? 'Slack'} so it can ask you before acting`, href: tab('messaging') };
|
|
134
|
+
default:
|
|
135
|
+
return { label: need.description };
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (candidate.trigger.kind === 'schedule')
|
|
139
|
+
items.push({ label: `Check when it runs (${candidate.trigger.description}), then turn its schedule on`, href: tab('schedules') });
|
|
140
|
+
return items;
|
|
141
|
+
}
|
|
142
|
+
export function previewCandidate(id, index, deps) {
|
|
143
|
+
return describeCandidate(candidateAt(getInterview(id), index), deps);
|
|
144
|
+
}
|
|
145
|
+
/** What creating `candidate` would make, under the first free name.
|
|
146
|
+
* Shared with templates (web/templates-admin.ts). */
|
|
147
|
+
export function describeCandidate(candidate, deps) {
|
|
148
|
+
const name = freeAgentName(candidate.agent.name, deps.agentExists);
|
|
149
|
+
return {
|
|
150
|
+
name,
|
|
151
|
+
systemPrompt: candidate.agent.systemPrompt,
|
|
152
|
+
permissions: `Every tool asks you first until you change it${candidate.asksFirst.length ? `, including ${candidate.asksFirst.join(', ')}` : ''}.`,
|
|
153
|
+
...(candidate.trigger.kind === 'schedule' ? { schedule: `${candidate.trigger.description} (created paused)` } : {}),
|
|
154
|
+
checklist: setupChecklist(candidate, name),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** Creates the agent: unmatched tools ask (a new agent's default is to
|
|
158
|
+
* deny), and a schedule, if any, is created paused until setup is done. */
|
|
159
|
+
export async function createFromCandidate(id, index, deps) {
|
|
160
|
+
return locks.run(id, async () => {
|
|
161
|
+
const interview = getInterview(id);
|
|
162
|
+
const candidate = candidateAt(interview, index);
|
|
163
|
+
if (interview.created[String(index)] && deps.agentExists(interview.created[String(index)])) {
|
|
164
|
+
throw new InterviewError(`Already created as ${interview.created[String(index)]}`);
|
|
165
|
+
}
|
|
166
|
+
const created = await createAgentFromCandidate(candidate, deps);
|
|
167
|
+
interview.created[String(index)] = created.name;
|
|
168
|
+
save(interview);
|
|
169
|
+
return created;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/** Creates `candidate` as an agent under the first free name: the agent
|
|
173
|
+
* itself, its permission default switched to "ask", and a paused
|
|
174
|
+
* schedule when it runs on a timer. Shared with templates
|
|
175
|
+
* (web/templates-admin.ts). */
|
|
176
|
+
export async function createAgentFromCandidate(candidate, deps) {
|
|
177
|
+
const name = freeAgentName(candidate.agent.name, deps.agentExists);
|
|
178
|
+
await deps.createAgent(name, candidate.agent.systemPrompt);
|
|
179
|
+
setDefaultDecision(name, 'ask');
|
|
180
|
+
let scheduleError;
|
|
181
|
+
if (candidate.trigger.kind === 'schedule') {
|
|
182
|
+
try {
|
|
183
|
+
const task = await createScheduledTask({
|
|
184
|
+
agent: name,
|
|
185
|
+
message: candidate.agent.scheduleMessage ?? candidate.summary,
|
|
186
|
+
schedule: { every: candidate.trigger.every ?? '1d' },
|
|
187
|
+
task_name: candidate.title,
|
|
188
|
+
});
|
|
189
|
+
await pauseScheduledTask(task.task_id);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
scheduleError = err instanceof Error ? err.message : String(err);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { name, checklist: setupChecklist(candidate, name), ...(candidate.agent.exampleMessage ? { exampleMessage: candidate.agent.exampleMessage } : {}), ...(scheduleError ? { scheduleError } : {}) };
|
|
196
|
+
}
|
|
197
|
+
/** The interviewer runs on the workspace's own model: borrowed from an
|
|
198
|
+
* agent that has one set, preferring one whose provider key is set.
|
|
199
|
+
* undefined when no agent names a model. */
|
|
200
|
+
export function pickInterviewModel(agents) {
|
|
201
|
+
const chosen = pickModelAgent(agents);
|
|
202
|
+
if (!chosen)
|
|
203
|
+
return undefined;
|
|
204
|
+
return { modelCall: chosen.agent.createModelCall(), model: chosen.model };
|
|
205
|
+
}
|
|
206
|
+
/** The model a new agent should start on: the same one the interviewer
|
|
207
|
+
* would pick, without building a model call (which throws when its key
|
|
208
|
+
* isn't set). Templates use it. */
|
|
209
|
+
export function pickAgentModel(agents) {
|
|
210
|
+
return pickModelAgent(agents)?.model;
|
|
211
|
+
}
|
|
212
|
+
function pickModelAgent(agents) {
|
|
213
|
+
const withModel = agents.filter((a) => a.config.model && typeof a.config.model === 'object' && 'provider' in a.config.model);
|
|
214
|
+
const keyed = withModel.find((a) => {
|
|
215
|
+
const envVar = modelKeyEnvVar(a.config.model.provider);
|
|
216
|
+
return envVar ? Boolean(process.env[envVar]) : false;
|
|
217
|
+
});
|
|
218
|
+
const chosen = keyed ?? withModel[0];
|
|
219
|
+
if (!chosen)
|
|
220
|
+
return undefined;
|
|
221
|
+
const m = chosen.config.model;
|
|
222
|
+
return { agent: chosen, model: { provider: m.provider, ...(m.model ? { model: m.model } : {}) } };
|
|
223
|
+
}
|
|
224
|
+
/** The interviewer's read-only tools over this workspace. */
|
|
225
|
+
export function interviewTools(agents) {
|
|
226
|
+
return {
|
|
227
|
+
async listInstalledAbilities() {
|
|
228
|
+
return agents.flatMap((a) => {
|
|
229
|
+
try {
|
|
230
|
+
return listInstalledAbilities(a.name).map((ability) => ({ agent: a.name, name: ability.name }));
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
async searchPublicAbilities(query) {
|
|
238
|
+
const results = await searchPublicAbilities(query);
|
|
239
|
+
return results.slice(0, 10).map((r) => ({ name: r.name, ...(r.description ? { description: r.description } : {}) }));
|
|
240
|
+
},
|
|
241
|
+
async listConnections() {
|
|
242
|
+
const providers = ['anthropic', 'openai', 'deepseek', 'kimi', 'glm', 'gemini'].filter((p) => {
|
|
243
|
+
const envVar = modelKeyEnvVar(p);
|
|
244
|
+
return envVar ? Boolean(process.env[envVar]) : false;
|
|
245
|
+
});
|
|
246
|
+
const messaging = agents.flatMap((a) => {
|
|
247
|
+
try {
|
|
248
|
+
const n = describeNotifier(a.config);
|
|
249
|
+
return n.source === 'none' ? [] : [{ agent: a.name, app: n.channel }];
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return { modelProvidersWithKey: providers, messaging, agents: agents.map((a) => a.name) };
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const interviewPageHtml: string;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// The console's "Find your first agent" page (/interview): a chat with the
|
|
2
|
+
// built-in interviewer (core/system-agents/interviewer.ts), the ranked
|
|
3
|
+
// candidates it finds, and Create this agent (web/interview-admin.ts
|
|
4
|
+
// behind /interviews/*). Same shell and styles as the other console
|
|
5
|
+
// pages. Like them, its inline script avoids backslashes and template
|
|
6
|
+
// placeholders, since it lives inside a template literal.
|
|
7
|
+
import { devUiCss } from './dev-ui-styles.js';
|
|
8
|
+
import { consoleShellScript, consoleSidebarHtml } from './console-shell.js';
|
|
9
|
+
export const interviewPageHtml = `<!doctype html>
|
|
10
|
+
<html lang="en">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="utf-8">
|
|
13
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
14
|
+
<title>Find your first agent</title>
|
|
15
|
+
<style>${devUiCss}
|
|
16
|
+
.iv-steps { display: flex; gap: 6px; flex-wrap: wrap; font-size: 12px; color: var(--ink-faint); }
|
|
17
|
+
.iv-steps span { padding: 2px 8px; border-radius: 999px; background: var(--surface); }
|
|
18
|
+
.iv-steps span.on { background: var(--accent-soft); color: var(--accent-ink); font-weight: 600; }
|
|
19
|
+
.iv-chat { display: grid; gap: 10px; max-width: 760px; }
|
|
20
|
+
.iv-msg { padding: 10px 14px; border-radius: 12px; white-space: pre-wrap; line-height: 1.5; font-size: 14px; max-width: 85%; }
|
|
21
|
+
.iv-msg.assistant { background: var(--surface); justify-self: start; }
|
|
22
|
+
.iv-msg.user { background: var(--accent-soft); justify-self: end; }
|
|
23
|
+
.iv-compose { display: grid; gap: 8px; max-width: 760px; }
|
|
24
|
+
.iv-compose textarea { min-height: 72px; font: inherit; padding: 10px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg); color: var(--ink); }
|
|
25
|
+
.iv-compose .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
26
|
+
.iv-cards { display: grid; gap: 12px; max-width: 760px; }
|
|
27
|
+
.iv-card { border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; display: grid; gap: 8px; }
|
|
28
|
+
.iv-card h3 { margin: 0; font-size: 15px; }
|
|
29
|
+
.iv-card .meta { font-size: 12.5px; color: var(--ink-muted); display: grid; gap: 4px; }
|
|
30
|
+
.iv-card .row { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
31
|
+
.iv-preview { border-top: 1px solid var(--line); padding-top: 10px; display: grid; gap: 8px; font-size: 13px; }
|
|
32
|
+
.iv-preview pre { white-space: pre-wrap; max-height: 180px; overflow: auto; margin: 0; }
|
|
33
|
+
.iv-list { display: grid; gap: 6px; max-width: 760px; }
|
|
34
|
+
.iv-list a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius); text-decoration: none; color: var(--ink); font-size: 13.5px; }
|
|
35
|
+
.iv-btn { display: inline-block; padding: 6px 12px; border-radius: 6px; text-decoration: none; font-size: 13px; font-weight: 500; background: var(--accent); color: var(--on-accent); }
|
|
36
|
+
.iv-btn.plain { background: none; color: var(--ink); border: 1px solid var(--line); }
|
|
37
|
+
.hint { font-size: 12px; color: var(--ink-muted); }
|
|
38
|
+
.error { font-size: 13px; color: var(--bad); }
|
|
39
|
+
</style>
|
|
40
|
+
</head>
|
|
41
|
+
<body data-le-page="interview">
|
|
42
|
+
<div class="le-app">
|
|
43
|
+
${consoleSidebarHtml}
|
|
44
|
+
<main class="le-main">
|
|
45
|
+
<div class="le-head plain">
|
|
46
|
+
<div class="le-head-row">
|
|
47
|
+
<div class="le-title"><button type="button" class="le-menu-btn" data-le-menu aria-label="Open navigation">☰</button>
|
|
48
|
+
<div><h1>Find your first agent</h1><p>A short interview about your work. It suggests up to three agents worth building, and creates the one you pick. Every agent it creates asks you before it sends, pays, changes or deletes anything.</p></div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="le-scroll"><div class="le-page" id="ivPage"><p class="hint">Loading…</p></div></div>
|
|
53
|
+
</main>
|
|
54
|
+
</div>
|
|
55
|
+
${consoleShellScript}
|
|
56
|
+
<script>
|
|
57
|
+
(function () {
|
|
58
|
+
var page = document.getElementById('ivPage');
|
|
59
|
+
var STAGES = [['work', 'Your work'], ['tools', 'Your tools'], ['tasks', 'Repeat tasks'], ['details', 'Details'], ['done', 'Results']];
|
|
60
|
+
var ELLIPSIS = String.fromCharCode(8230);
|
|
61
|
+
var current = null;
|
|
62
|
+
var busy = false;
|
|
63
|
+
|
|
64
|
+
function esc(s) {
|
|
65
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
|
66
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function api(method, path, body) {
|
|
70
|
+
return fetch(path, { method: method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined, cache: 'no-store' })
|
|
71
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); });
|
|
72
|
+
}
|
|
73
|
+
function setUrl(id) {
|
|
74
|
+
var url = new URL(location.href);
|
|
75
|
+
if (id) url.searchParams.set('id', id); else url.searchParams.delete('id');
|
|
76
|
+
history.replaceState(null, '', url.pathname + url.search);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- Start page: past interviews and a Start button ----
|
|
80
|
+
function showStart(message) {
|
|
81
|
+
current = null;
|
|
82
|
+
setUrl(null);
|
|
83
|
+
api('GET', '/interviews').then(function (r) {
|
|
84
|
+
var list = (r.ok && r.body.interviews) || [];
|
|
85
|
+
var ready = r.ok && r.body.ready;
|
|
86
|
+
page.innerHTML =
|
|
87
|
+
'<section style="max-width:760px"><p>About 10 minutes, a few questions at a time, in any language. Nothing is created until you choose to.</p>' +
|
|
88
|
+
(ready ? '<button type="button" class="primary" id="ivStart">Start the interview</button>'
|
|
89
|
+
: '<p class="error">The interviewer uses the model of one of your agents, and none has a model set yet. Set one on an agent (Overview, then Model) and add its key under Models.</p>') +
|
|
90
|
+
(message ? '<p class="error">' + esc(message) + '</p>' : '') + '</section>' +
|
|
91
|
+
(list.length ? '<section><h3>Your interviews</h3><div class="iv-list">' + list.map(function (iv) {
|
|
92
|
+
var made = Object.keys(iv.created || {}).length;
|
|
93
|
+
var label = iv.candidates && iv.candidates.length ? iv.candidates.length + ' suggestion' + (iv.candidates.length === 1 ? '' : 's') + (made ? ', ' + made + ' created' : '') : 'In progress';
|
|
94
|
+
return '<a href="?id=' + esc(iv.id) + '" data-id="' + esc(iv.id) + '"><span>' + esc(new Date(iv.startedAt).toLocaleString()) + '</span><span class="hint">' + esc(label) + '</span></a>';
|
|
95
|
+
}).join('') + '</div></section>' : '');
|
|
96
|
+
var start = document.getElementById('ivStart');
|
|
97
|
+
if (start) start.addEventListener('click', function () {
|
|
98
|
+
start.disabled = true;
|
|
99
|
+
api('POST', '/interviews').then(function (r2) {
|
|
100
|
+
if (!r2.ok) { showStart(r2.body.error || 'Could not start'); return; }
|
|
101
|
+
showInterview(r2.body);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
Array.prototype.forEach.call(page.querySelectorAll('.iv-list a'), function (a) {
|
|
105
|
+
a.addEventListener('click', function (ev) { ev.preventDefault(); load(a.getAttribute('data-id')); });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function load(id) {
|
|
111
|
+
api('GET', '/interviews/' + encodeURIComponent(id)).then(function (r) {
|
|
112
|
+
if (!r.ok) { showStart(r.body.error || 'Could not open that interview'); return; }
|
|
113
|
+
showInterview(r.body);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- The interview: steps, chat, answer box, results ----
|
|
118
|
+
function stepsHtml(stage) {
|
|
119
|
+
var at = STAGES.map(function (s) { return s[0]; }).indexOf(stage);
|
|
120
|
+
return '<div class="iv-steps">' + STAGES.map(function (s, i) {
|
|
121
|
+
return '<span class="' + (i === at ? 'on' : '') + '">' + s[1] + '</span>';
|
|
122
|
+
}).join('') + '</div>';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function cardHtml(c, i, iv) {
|
|
126
|
+
var made = iv.created && iv.created[String(i)];
|
|
127
|
+
var needs = (c.needs || []).map(function (n) { return esc(n.name || n.description); }).join(', ');
|
|
128
|
+
return '<div class="iv-card" data-index="' + i + '">' +
|
|
129
|
+
'<h3>' + (i + 1) + '. ' + esc(c.title) + ' <span class="hint">' + String.fromCharCode(183) + ' saves about ' + esc(Math.round(c.hoursPerWeek * 10) / 10) + ' h a week</span></h3>' +
|
|
130
|
+
'<div>' + esc(c.summary) + '</div>' +
|
|
131
|
+
'<div class="meta">' +
|
|
132
|
+
'<div><strong>Starts:</strong> ' + esc(c.trigger.description) + '</div>' +
|
|
133
|
+
(c.asksFirst && c.asksFirst.length ? '<div><strong>Asks you first:</strong> ' + esc(c.asksFirst.join(', ')) + '</div>' : '') +
|
|
134
|
+
(needs ? '<div><strong>Needs:</strong> ' + needs + '</div>' : '') +
|
|
135
|
+
'</div>' +
|
|
136
|
+
'<div class="row">' + (made
|
|
137
|
+
? '<a class="iv-btn" href="/agents/config?agent=' + encodeURIComponent(made) + '">Open ' + esc(made) + '</a>'
|
|
138
|
+
: '<button type="button" class="primary iv-preview-btn">Create this agent</button>') +
|
|
139
|
+
'</div><div class="iv-slot"></div></div>';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function showInterview(iv) {
|
|
143
|
+
current = iv;
|
|
144
|
+
setUrl(iv.id);
|
|
145
|
+
var done = iv.stage === 'done' && iv.candidates && iv.candidates.length;
|
|
146
|
+
page.innerHTML =
|
|
147
|
+
stepsHtml(iv.stage) +
|
|
148
|
+
'<div class="iv-chat" id="ivChat">' + iv.display.map(function (m) {
|
|
149
|
+
return '<div class="iv-msg ' + (m.role === 'user' ? 'user' : 'assistant') + '">' + esc(m.text) + '</div>';
|
|
150
|
+
}).join('') + '</div>' +
|
|
151
|
+
(done ? '<section><h3>Agents worth building</h3><div class="iv-cards">' + iv.candidates.map(function (c, i) { return cardHtml(c, i, iv); }).join('') + '</div></section>' : '') +
|
|
152
|
+
'<form class="iv-compose" id="ivCompose"><textarea name="text" placeholder="' + (done ? 'Ask about a suggestion, or for a change' : 'Your answer') + '"></textarea>' +
|
|
153
|
+
'<div class="row"><button type="submit" class="primary">Send</button>' +
|
|
154
|
+
(done ? '' : '<button type="button" id="ivFinish">Finish now</button>') +
|
|
155
|
+
'<a href="/interview" id="ivBack" class="hint" style="margin-left:auto">All interviews</a></div>' +
|
|
156
|
+
'<div id="ivStatus" class="hint"></div></form>';
|
|
157
|
+
var chat = document.getElementById('ivChat');
|
|
158
|
+
var last = chat.lastElementChild;
|
|
159
|
+
if (last && !done) last.scrollIntoView({ block: 'end' });
|
|
160
|
+
var form = document.getElementById('ivCompose');
|
|
161
|
+
var status = document.getElementById('ivStatus');
|
|
162
|
+
form.addEventListener('submit', function (ev) {
|
|
163
|
+
ev.preventDefault();
|
|
164
|
+
var text = form.querySelector('textarea').value.trim();
|
|
165
|
+
if (!text || busy) return;
|
|
166
|
+
busy = true;
|
|
167
|
+
chat.insertAdjacentHTML('beforeend', '<div class="iv-msg user">' + esc(text) + '</div><div class="iv-msg assistant hint" id="ivTyping">' + ELLIPSIS + '</div>');
|
|
168
|
+
form.querySelector('textarea').value = '';
|
|
169
|
+
document.getElementById('ivTyping').scrollIntoView({ block: 'end' });
|
|
170
|
+
api('POST', '/interviews/' + encodeURIComponent(iv.id) + '/messages', { text: text }).then(function (r) {
|
|
171
|
+
busy = false;
|
|
172
|
+
if (!r.ok) { var t = document.getElementById('ivTyping'); if (t) t.remove(); status.textContent = r.body.error || 'Something went wrong'; return; }
|
|
173
|
+
showInterview(r.body);
|
|
174
|
+
}).catch(function () { busy = false; status.textContent = 'Could not reach the server; try again.'; });
|
|
175
|
+
});
|
|
176
|
+
form.querySelector('textarea').addEventListener('keydown', function (ev) {
|
|
177
|
+
if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey)) form.requestSubmit();
|
|
178
|
+
});
|
|
179
|
+
var finish = document.getElementById('ivFinish');
|
|
180
|
+
if (finish) finish.addEventListener('click', function () {
|
|
181
|
+
if (busy) return;
|
|
182
|
+
busy = true;
|
|
183
|
+
finish.disabled = true;
|
|
184
|
+
status.textContent = 'Ranking what it has' + ELLIPSIS;
|
|
185
|
+
api('POST', '/interviews/' + encodeURIComponent(iv.id) + '/finish').then(function (r) {
|
|
186
|
+
busy = false;
|
|
187
|
+
if (!r.ok) { finish.disabled = false; status.textContent = r.body.error || 'Something went wrong'; return; }
|
|
188
|
+
showInterview(r.body);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
document.getElementById('ivBack').addEventListener('click', function (ev) { ev.preventDefault(); showStart(); });
|
|
192
|
+
Array.prototype.forEach.call(page.querySelectorAll('.iv-preview-btn'), function (btn) {
|
|
193
|
+
btn.addEventListener('click', function () { preview(btn.closest('.iv-card')); });
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---- Create this agent: preview, then create, then Finish setup ----
|
|
198
|
+
function checklistHtml(items) {
|
|
199
|
+
if (!items.length) return '<p>Nothing else to set up.</p>';
|
|
200
|
+
return '<ul style="margin:0;padding-left:18px">' + items.map(function (it) {
|
|
201
|
+
return '<li>' + (it.href ? '<a href="' + esc(it.href) + '">' + esc(it.label) + '</a>' : esc(it.label)) + '</li>';
|
|
202
|
+
}).join('') + '</ul>';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function preview(card) {
|
|
206
|
+
var i = card.getAttribute('data-index');
|
|
207
|
+
var slot = card.querySelector('.iv-slot');
|
|
208
|
+
slot.innerHTML = '<p class="hint">Loading' + ELLIPSIS + '</p>';
|
|
209
|
+
api('GET', '/interviews/' + encodeURIComponent(current.id) + '/candidates/' + i + '/preview').then(function (r) {
|
|
210
|
+
if (!r.ok) { slot.innerHTML = '<p class="error">' + esc(r.body.error || 'Could not prepare it') + '</p>'; return; }
|
|
211
|
+
var p = r.body;
|
|
212
|
+
slot.innerHTML = '<div class="iv-preview">' +
|
|
213
|
+
'<div><strong>Agent:</strong> <code>' + esc(p.name) + '</code></div>' +
|
|
214
|
+
'<div><strong>Instructions:</strong><pre>' + esc(p.systemPrompt) + '</pre></div>' +
|
|
215
|
+
'<div><strong>Permissions:</strong> ' + esc(p.permissions) + '</div>' +
|
|
216
|
+
(p.schedule ? '<div><strong>Schedule:</strong> ' + esc(p.schedule) + '</div>' : '') +
|
|
217
|
+
'<div><strong>Then, to finish setup:</strong>' + checklistHtml(p.checklist) + '</div>' +
|
|
218
|
+
'<div class="row"><button type="button" class="primary iv-create">Create it</button><button type="button" class="iv-cancel">Cancel</button></div>' +
|
|
219
|
+
'<div class="hint iv-create-status"></div></div>';
|
|
220
|
+
slot.querySelector('.iv-cancel').addEventListener('click', function () { slot.innerHTML = ''; });
|
|
221
|
+
slot.querySelector('.iv-create').addEventListener('click', function (ev) {
|
|
222
|
+
var btn = ev.currentTarget;
|
|
223
|
+
btn.disabled = true;
|
|
224
|
+
slot.querySelector('.iv-create-status').textContent = 'Creating' + ELLIPSIS;
|
|
225
|
+
api('POST', '/interviews/' + encodeURIComponent(current.id) + '/candidates/' + i + '/create').then(function (r2) {
|
|
226
|
+
if (!r2.ok) { btn.disabled = false; slot.querySelector('.iv-create-status').textContent = r2.body.error || 'Could not create it'; return; }
|
|
227
|
+
var c = r2.body;
|
|
228
|
+
slot.innerHTML = '<div class="iv-preview">' +
|
|
229
|
+
'<div><strong>Created <code>' + esc(c.name) + '</code>.</strong> It asks you before every tool until you change that in its Permissions tab.</div>' +
|
|
230
|
+
(c.scheduleError ? '<div class="error">Its schedule wasn' + String.fromCharCode(8217) + 't created: ' + esc(c.scheduleError) + '. Add one in its Schedules tab.</div>' : '') +
|
|
231
|
+
'<div><strong>Finish setup:</strong>' + checklistHtml(c.checklist) + '</div>' +
|
|
232
|
+
(c.exampleMessage ? '<div><strong>Try it</strong> in Chat with: <em>' + esc(c.exampleMessage) + '</em></div>' : '') +
|
|
233
|
+
'<div class="row"><a class="iv-btn" href="/agents/config?agent=' + encodeURIComponent(c.name) + '">Open ' + esc(c.name) + '</a>' +
|
|
234
|
+
'<a class="iv-btn plain" href="' + esc(leShell.tabHref(c.name, 'chat')) + '">Try it in Chat</a></div></div>';
|
|
235
|
+
current.created = current.created || {};
|
|
236
|
+
current.created[String(i)] = c.name;
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
var initial = new URLSearchParams(location.search).get('id');
|
|
243
|
+
if (initial) load(initial); else showStart();
|
|
244
|
+
})();
|
|
245
|
+
</script>
|
|
246
|
+
</body>
|
|
247
|
+
</html>`;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type CandidatePreview, type CreateDeps, type CreatedAgent } from './interview-admin.js';
|
|
2
|
+
export declare class TemplateError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/** A template as the page's cards show it. */
|
|
5
|
+
export interface TemplateSummary {
|
|
6
|
+
id: string;
|
|
7
|
+
category: string;
|
|
8
|
+
title: string;
|
|
9
|
+
summary: string;
|
|
10
|
+
trigger: string;
|
|
11
|
+
asksFirst: string[];
|
|
12
|
+
needs: string[];
|
|
13
|
+
schedule: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function listTemplates(): TemplateSummary[];
|
|
16
|
+
export declare function previewTemplate(id: string, deps: Pick<CreateDeps, 'agentExists'>): CandidatePreview;
|
|
17
|
+
/** Creates a new agent from the template, under the first free name, so
|
|
18
|
+
* the same template can be used more than once. */
|
|
19
|
+
export declare function createFromTemplate(id: string, deps: CreateDeps): Promise<CreatedAgent>;
|