create-pmos 0.38.8 → 0.39.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +29 -1
- package/workspace/guide/index.md +38 -0
- package/workspace/guide/initiatives-and-next-steps.md +72 -0
- package/workspace/guide/layout.md +59 -0
- package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
- package/workspace/guide/statuses.md +53 -0
- package/workspace/guide/validate-with-typesafe.md +47 -0
- package/workspace/lib/initiative-files.js +29 -6
- package/workspace/lib/judgment.js +65 -0
- package/workspace/lib/mobile-nav.js +1 -1
- package/workspace/lib/next-step-actions.js +139 -0
- package/workspace/lib/next-steps.js +27 -7
- package/workspace/lib/schedule-cli.js +5 -2
- package/workspace/lib/secret-scan.js +28 -0
- package/workspace/lib/session-budget.js +35 -0
- package/workspace/lib/status-groups.js +53 -10
- package/workspace/lib/typesafe-settings.js +119 -0
- package/workspace/lib/typesafe.js +124 -0
- package/workspace/lib/validate.js +304 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/pm/app.js +53 -18
- package/workspace/public/pm/data.js +18 -3
- package/workspace/public/pm/mobile.js +1 -1
- package/workspace/public/pm/modes-live.js +152 -0
- package/workspace/public/pm/modes.js +25 -0
- package/workspace/public/pm/next-step-ui.js +185 -0
- package/workspace/public/pm/panel-detail.js +47 -11
- package/workspace/public/pm/panel-initiatives.js +26 -4
- package/workspace/public/pm/panel-repos.js +2 -2
- package/workspace/public/pm/state.js +3 -3
- package/workspace/public/pm/status-banner.js +87 -0
- package/workspace/public/pm/tab-doc.js +11 -6
- package/workspace/server.js +323 -4
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* next-step-actions.js — what a next step can DO, whether it may be started yet, and the prompt a session would get.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no I/O, no clock, no model. The server supplies facts (does a file exist, which sources a profile declares) and
|
|
5
|
+
* this module decides. Three questions, each answered in one place so no panel and no route re-decides them:
|
|
6
|
+
*
|
|
7
|
+
* actFor(item) what kind of act the step is: open · session · work · gate · lifecycle · none
|
|
8
|
+
* refusal(item, ns) why a `work` step may NOT be started yet, or null when it may
|
|
9
|
+
* buildPrompt(...) the exact first input a session would receive — what the PM is shown before pressing go
|
|
10
|
+
*
|
|
11
|
+
* THE BOUNDARY (D12 and the control/factory invariant). A session started from here PROPOSES: it commits to a local
|
|
12
|
+
* initiative branch or writes draft files. It never accepts, approves a contract, merges, pushes, sends or posts. Acceptance
|
|
13
|
+
* and the contract approval are `gate` acts and are never turned into `work`.
|
|
14
|
+
*/
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
// Ladder steps an agent session can do. `contract`, `accept`, `approved`, `outcome` and `handoff` are the PM's own act.
|
|
18
|
+
const WORK_KEYS = new Set(['route', 'anchor', 'prototype', 'understand', 'spec', 'priors', 'build', 'quality', 'review', 'explain', 'brief', 'draft']);
|
|
19
|
+
const GATE_KEYS = new Set(['contract', 'accept', 'approved', 'outcome', 'handoff']);
|
|
20
|
+
const KNOWN_KEYS = [...WORK_KEYS, ...GATE_KEYS];
|
|
21
|
+
// The act types a step can have (`none` means no action is defined, and is not offered). The user guide names each one.
|
|
22
|
+
const ACT_TYPES = ['open', 'session', 'work', 'gate', 'lifecycle'];
|
|
23
|
+
|
|
24
|
+
const GATE_WHY = {
|
|
25
|
+
contract: 'The contract is yours to approve. An agent may draft it; the approval is recorded by you, never by a session.',
|
|
26
|
+
accept: 'Acceptance is yours and cannot be delegated (D12).',
|
|
27
|
+
approved: 'Approval is yours.',
|
|
28
|
+
outcome: 'The outcome check is yours to record.',
|
|
29
|
+
handoff: 'Handing work to another system is outward-facing, so it is yours to press.',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** @returns {{type:'open'|'session'|'work'|'gate'|'lifecycle'|'none', why:string, path?:string, sessionId?:string}} */
|
|
33
|
+
function actFor(item) {
|
|
34
|
+
if (!item || typeof item !== 'object') return { type: 'none', why: 'no step' };
|
|
35
|
+
const path = typeof item.path === 'string' && item.path ? item.path : null;
|
|
36
|
+
const withPath = (a) => (path ? { ...a, path } : a);
|
|
37
|
+
switch (item.kind) {
|
|
38
|
+
case 'reopen': return { type: 'lifecycle', why: 'Reopen it to put it back on the board.' };
|
|
39
|
+
case 'session': return item.sid ? { type: 'session', why: 'Switch to the running session.', sessionId: item.sid } : { type: 'none', why: 'the session is not identified' };
|
|
40
|
+
case 'accept': return withPath({ type: path ? 'open' : 'gate', why: GATE_WHY.accept });
|
|
41
|
+
case 'needs': return withPath({ type: 'gate', why: 'An open item only you can answer.' });
|
|
42
|
+
case 'returned': return { type: 'work', why: 'Address the return, then ask for acceptance again.' };
|
|
43
|
+
case 'step': {
|
|
44
|
+
if (WORK_KEYS.has(item.key)) return withPath({ type: 'work', why: 'An agent session can do this step.' });
|
|
45
|
+
if (GATE_KEYS.has(item.key)) return withPath({ type: path ? 'open' : 'gate', why: GATE_WHY[item.key] });
|
|
46
|
+
return { type: 'none', why: `no action is defined for the step "${item.key}"` };
|
|
47
|
+
}
|
|
48
|
+
default: return { type: 'none', why: 'no action is defined for this item' };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Set `act` on every item of a derived next-steps result. Mutates and returns it. */
|
|
53
|
+
function annotate(ns, pathOf) {
|
|
54
|
+
if (!ns || !Array.isArray(ns.items)) return ns;
|
|
55
|
+
for (const it of ns.items) {
|
|
56
|
+
if (typeof pathOf === 'function') { const p = pathOf(it); if (p) it.path = p; }
|
|
57
|
+
it.act = actFor(it);
|
|
58
|
+
}
|
|
59
|
+
return ns;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Why this item may not be started as work yet; null when it may. `ns` is the whole derived result. */
|
|
63
|
+
function refusal(item, ns) {
|
|
64
|
+
if (!item) return { code: 'no-step', why: 'no such step' };
|
|
65
|
+
const act = item.act || actFor(item);
|
|
66
|
+
if (ns && ns.state === 'stopped' && item.kind !== 'reopen') {
|
|
67
|
+
return { code: 'stopped', why: 'This initiative is stopped (held, closed or cancelled). Reopen it first.', first: 'reopen' };
|
|
68
|
+
}
|
|
69
|
+
if (ns && ns.state === 'accepted') return { code: 'accepted', why: 'This initiative is accepted. Nothing remains.' };
|
|
70
|
+
if (act.type !== 'work') return { code: 'not-work', why: act.type === 'gate' ? act.why : `This is not work an agent session can start (${act.type}).` };
|
|
71
|
+
const items = (ns && ns.items) || [];
|
|
72
|
+
if (item.kind === 'returned') return null; // addressing a return is always the next thing
|
|
73
|
+
if (item.status === 'blocked') return { code: 'blocked', why: `This step is blocked${item.why ? ' — ' + item.why : ''}.`, first: item.key };
|
|
74
|
+
if (item.key === 'build') {
|
|
75
|
+
const contract = items.find((x) => x.kind === 'step' && x.key === 'contract');
|
|
76
|
+
if (contract) return { code: 'no-contract', why: 'The build cannot start before you approve the pre-run contract. An agent may draft it; you approve it.', first: 'contract' };
|
|
77
|
+
}
|
|
78
|
+
if (item.first || item.status === 'current') return null;
|
|
79
|
+
const first = items.find((x) => x.first) || items[0];
|
|
80
|
+
return { code: 'not-reached', why: `Not reached yet: step ${first ? first.n : 1} (${first ? first.key : 'the first step'}) comes first.`, first: first ? first.key : null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Which read-only sources suit which step. Names are the keys of SOURCE_MCP below.
|
|
84
|
+
const STEP_SOURCES = {
|
|
85
|
+
route: ['jira', 'slack'], understand: ['wispr', 'confluence', 'jira', 'slack'], spec: ['wispr', 'confluence', 'jira', 'gmail'],
|
|
86
|
+
priors: ['confluence'], prototype: ['wispr', 'slack'], build: ['jira', 'confluence'], returned: ['wispr', 'slack', 'gmail'],
|
|
87
|
+
brief: ['wispr', 'calendar', 'gmail'], draft: ['wispr', 'confluence', 'gmail'],
|
|
88
|
+
};
|
|
89
|
+
// MCP server names a profile may declare, per source. A name outside this table names nothing.
|
|
90
|
+
const SOURCE_MCP = {
|
|
91
|
+
wispr: ['wispr', 'wispr-flow', 'wisprflow'], gmail: ['gmail', 'google-mail'], calendar: ['google-calendar', 'gcal', 'calendar'],
|
|
92
|
+
confluence: ['confluence', 'atlassian'], jira: ['jira', 'atlassian'], slack: ['slack'],
|
|
93
|
+
};
|
|
94
|
+
const SOURCE_LABEL = { wispr: 'Wispr Flow notes and meetings', gmail: 'Gmail', calendar: 'Google Calendar', confluence: 'Confluence', jira: 'Jira', slack: 'Slack' };
|
|
95
|
+
|
|
96
|
+
/** The sources the prompt may name: suited to the step AND declared by the chosen profile's `mcp` list. */
|
|
97
|
+
function sourcesFor(item, mcpDeclared) {
|
|
98
|
+
const declared = new Set((Array.isArray(mcpDeclared) ? mcpDeclared : []).map((s) => String(s).toLowerCase()));
|
|
99
|
+
const want = STEP_SOURCES[item && item.kind === 'returned' ? 'returned' : item && item.key] || [];
|
|
100
|
+
return want.filter((s) => (SOURCE_MCP[s] || []).some((n) => declared.has(n))).map((s) => ({ id: s, label: SOURCE_LABEL[s] }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const RULES = [
|
|
104
|
+
'Commit only to this local branch, or write draft files in the repository.',
|
|
105
|
+
'Do not push, open a pull request, send or post anything, create a ticket, record an acceptance, approve a contract, or merge. Those are the PM\'s.',
|
|
106
|
+
'Do only this step. If you find another problem, write it down and stop.',
|
|
107
|
+
'When you finish, stop and write a short summary: what you changed, and what you could not verify.',
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The exact first input a session would receive.
|
|
112
|
+
* @param {{initiative:string, title?:string, item:object, branch:string|null, sources:Array, judgment:string}} p
|
|
113
|
+
*/
|
|
114
|
+
function buildPrompt(p) {
|
|
115
|
+
const it = p.item || {};
|
|
116
|
+
const lines = [];
|
|
117
|
+
lines.push(`Work on initiative ${p.initiative}${p.title ? ' — ' + p.title : ''}.`);
|
|
118
|
+
lines.push(`Step: ${it.key || it.kind}${it.text && it.text !== it.key ? ' — ' + it.text : ''}.`);
|
|
119
|
+
if (it.action) lines.push(`What is needed: ${it.action}`);
|
|
120
|
+
if (it.why && it.why !== it.action) lines.push(`Why this step is open: ${it.why}`);
|
|
121
|
+
lines.push(`You are on ${p.branch ? 'branch ' + p.branch : 'the current branch'}. Read AGENTS.md, then this initiative's record, PRD and rubric in planning/ before you act.`);
|
|
122
|
+
lines.push('');
|
|
123
|
+
lines.push('Rules that do not bend:');
|
|
124
|
+
for (const r of RULES) lines.push('- ' + r);
|
|
125
|
+
if (p.sources && p.sources.length) {
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push('You may read these sources for context. They are read-only: look, do not write.');
|
|
128
|
+
for (const s of p.sources) lines.push('- ' + s.label);
|
|
129
|
+
}
|
|
130
|
+
if (p.judgment) { lines.push(''); lines.push(p.judgment); }
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A short fingerprint of what the PM is shown. Go must carry it back; if anything changed, the start is refused. */
|
|
135
|
+
function planHash(parts) {
|
|
136
|
+
return crypto.createHash('sha256').update(JSON.stringify([parts.prompt, parts.branch || null, parts.wsId || null, parts.plan || null, !!parts.judgment])).digest('hex').slice(0, 16);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { actFor, annotate, refusal, sourcesFor, buildPrompt, planHash, ACT_TYPES, KNOWN_KEYS, WORK_KEYS, GATE_KEYS, RULES, SOURCE_LABEL };
|
|
@@ -19,12 +19,18 @@
|
|
|
19
19
|
|
|
20
20
|
const REMAINING = new Set(['blocked', 'current', 'pending', 'unknown']);
|
|
21
21
|
|
|
22
|
+
// Who acts on a step. Acceptance, the contract approval, a document's approval and an outcome check are the PM's;
|
|
23
|
+
// every other ladder step is the agent's. This is the one place that is decided: later work reads `owner`, it
|
|
24
|
+
// does not re-decide it.
|
|
25
|
+
const PM_STEPS = new Set(['accept', 'contract', 'approved', 'outcome']);
|
|
26
|
+
const slug = (t) => String(t == null ? '' : t).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
|
|
27
|
+
|
|
22
28
|
const clip = (s, n = 200) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
23
29
|
|
|
24
30
|
function stepItem(s) {
|
|
25
31
|
const unread = s.status === 'unknown';
|
|
26
32
|
return {
|
|
27
|
-
kind: 'step', key: s.key, status: s.status,
|
|
33
|
+
kind: 'step', key: s.key, status: s.status, owner: PM_STEPS.has(s.key) ? 'pm' : 'agent', act: null,
|
|
28
34
|
text: unread ? `${s.key} could not be read` : s.key,
|
|
29
35
|
action: clip(s.next || s.evidence || (unread ? 'the workflow engine could not decide this step' : 'no action recorded for this step')),
|
|
30
36
|
why: clip(s.evidence),
|
|
@@ -33,6 +39,18 @@ function stepItem(s) {
|
|
|
33
39
|
};
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
// A stable id: initiative + kind + the thing it is about (the step key, the session id, the words of an open item).
|
|
43
|
+
function withIds(id, items) {
|
|
44
|
+
const seen = new Map();
|
|
45
|
+
for (const it of items) {
|
|
46
|
+
const tail = it.kind === 'session' ? (it.sid || slug(it.text)) : it.kind === 'needs' ? slug(it.text) : it.key;
|
|
47
|
+
const base = `${id || 'initiative'}:${it.kind}:${tail}`;
|
|
48
|
+
const n = (seen.get(base) || 0) + 1; seen.set(base, n);
|
|
49
|
+
it.id = n > 1 ? `${base}:${n}` : base;
|
|
50
|
+
}
|
|
51
|
+
return items;
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
function deriveNextSteps(input) {
|
|
37
55
|
const i = input || {};
|
|
38
56
|
const row = i.ladder && i.ladder.ok && i.ladder.row ? i.ladder.row : null;
|
|
@@ -49,12 +67,13 @@ function deriveNextSteps(input) {
|
|
|
49
67
|
const stopped = i.lifecycle && i.lifecycle.state && i.lifecycle.state !== 'active' ? i.lifecycle : null;
|
|
50
68
|
if (stopped) {
|
|
51
69
|
const items = [{
|
|
52
|
-
kind: 'reopen', key: 'reopen', status: 'pending',
|
|
70
|
+
kind: 'reopen', key: 'reopen', status: 'pending', owner: 'pm', act: null,
|
|
53
71
|
text: `Reopen — it is ${stopped.state}`,
|
|
54
72
|
action: 'Reopen it to put it back on the board and have its steps gate again. Nothing below is required while it is stopped.',
|
|
55
73
|
why: clip(stopped.reason), source: 'lifecycle.jsonl (D70)',
|
|
56
74
|
}, ...ordered.map((s) => ({ ...stepItem(s), held: true }))];
|
|
57
75
|
items.forEach((it, n) => { it.n = n + 1; it.first = n === 0; });
|
|
76
|
+
withIds(i.id, items);
|
|
58
77
|
return {
|
|
59
78
|
state: 'stopped',
|
|
60
79
|
summary: `Stopped (${stopped.state})${stopped.reason ? ' — ' + clip(stopped.reason, 120) : ''}. It is off the board and not gating. Reopen to continue; the steps it would then owe are listed after.`,
|
|
@@ -73,26 +92,26 @@ function deriveNextSteps(input) {
|
|
|
73
92
|
const hasAcceptStep = steps.some((s) => s.key === 'accept');
|
|
74
93
|
for (const n of (i.needs || [])) {
|
|
75
94
|
items.push({
|
|
76
|
-
kind: 'needs', key: 'needs', status: 'pending', text: clip(n.question || '(unstated)', 160),
|
|
95
|
+
kind: 'needs', key: 'needs', status: 'pending', owner: 'pm', act: null, text: clip(n.question || '(unstated)', 160),
|
|
77
96
|
action: 'Answer or clear this open item.', why: '', source: 'needs queue',
|
|
78
97
|
});
|
|
79
98
|
}
|
|
80
99
|
if (i.returned) {
|
|
81
100
|
items.push({
|
|
82
|
-
kind: 'returned', key: 'returned', status: 'blocked', text: 'The PM returned this at the Acceptance Gate',
|
|
101
|
+
kind: 'returned', key: 'returned', status: 'blocked', owner: 'pm', act: null, text: 'The PM returned this at the Acceptance Gate',
|
|
83
102
|
action: 'Address the return, then request acceptance again.', why: '', source: 'acceptances.jsonl',
|
|
84
103
|
});
|
|
85
104
|
}
|
|
86
105
|
for (const s of live) {
|
|
87
106
|
items.push({
|
|
88
|
-
kind: 'session', key: 'session', status: 'current', text: `Session “${clip(s.name, 60)}” is running on it`,
|
|
107
|
+
kind: 'session', key: 'session', status: 'current', owner: 'agent', act: null, sid: s.id || null, text: `Session “${clip(s.name, 60)}” is running on it`,
|
|
89
108
|
action: 'Let it finish, or end it before accepting.', why: '', source: 'live sessions',
|
|
90
109
|
});
|
|
91
110
|
}
|
|
92
|
-
if (acceptStep) items.push({ ...stepItem(acceptStep), kind: 'accept', text: 'PM Acceptance' });
|
|
111
|
+
if (acceptStep) items.push({ ...stepItem(acceptStep), kind: 'accept', owner: 'pm', text: 'PM Acceptance' });
|
|
93
112
|
else if (!hasAcceptStep) {
|
|
94
113
|
items.push({
|
|
95
|
-
kind: 'accept', key: 'accept', status: 'pending', text: 'PM Acceptance',
|
|
114
|
+
kind: 'accept', key: 'accept', status: 'pending', owner: 'pm', act: null, text: 'PM Acceptance',
|
|
96
115
|
action: 'The PM names one thing verified by hand, then records the acceptance (D12). It is the PM’s and cannot be delegated.',
|
|
97
116
|
why: '', source: 'Acceptance Gate (D12)',
|
|
98
117
|
});
|
|
@@ -101,6 +120,7 @@ function deriveNextSteps(input) {
|
|
|
101
120
|
const engineNext = row.next && row.next.step ? row.next.step : null;
|
|
102
121
|
const firstIdx = engineNext ? Math.max(0, items.findIndex((x) => x.kind === 'step' && x.key === engineNext)) : 0;
|
|
103
122
|
items.forEach((it, n) => { it.n = n + 1; it.first = n === firstIdx; });
|
|
123
|
+
withIds(i.id, items);
|
|
104
124
|
if (!items.length) return { state: 'open', summary: 'No step is outstanding — the ladder shows every required step done. Acceptance is the PM’s.', items: [] };
|
|
105
125
|
const blockedN = items.filter((x) => x.status === 'blocked').length;
|
|
106
126
|
return {
|
|
@@ -245,7 +245,7 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
function usage(say) {
|
|
248
|
-
say(
|
|
248
|
+
say(`usage: pmos schedule <${COMMANDS.schedule.join('|')}> … | pmos skill <${COMMANDS.skill.join('|')}> …`);
|
|
249
249
|
say(' pmos schedule add --skill gather-requirements --cadence daily --time 07:30 [--until 2026-12-31] [--max-runs 20]');
|
|
250
250
|
say(' pmos schedule add --from-session <id> --skill-name my-check --cadence weekly --day Fri --time 16:00');
|
|
251
251
|
say(' pmos schedule edit <id|name> --time 08:00 --args "focus on the billing initiative" --tier deep --until none');
|
|
@@ -253,4 +253,7 @@ function usage(say) {
|
|
|
253
253
|
say(' schedules fire only while the workspace server is running.');
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
|
|
256
|
+
// The commands this module answers, in one place: the usage line below and the user guide's drift test both read it.
|
|
257
|
+
const COMMANDS = { schedule: ['list', 'show', 'add', 'edit', 'run', 'enable', 'disable', 'rm'], skill: ['list', 'show', 'new', 'edit', 'copy'] };
|
|
258
|
+
|
|
259
|
+
module.exports = { run, parseArgs, fieldsFrom, COMMANDS };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* secret-scan.js — a local check that runs BEFORE any repository text is sent anywhere (D79, V7).
|
|
3
|
+
*
|
|
4
|
+
* It returns the NAMES of the rules that matched and never the matched text, so a finding or a log
|
|
5
|
+
* line built from the result cannot carry the secret. A green scan is not proof that a text is
|
|
6
|
+
* clean: the per-repository consent switch is the real control, and this is the second line.
|
|
7
|
+
*/
|
|
8
|
+
const RULES = [
|
|
9
|
+
['private-key-block', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
|
10
|
+
['aws-access-key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
|
11
|
+
['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/],
|
|
12
|
+
['slack-token', /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/],
|
|
13
|
+
['api-key-sk', /\bsk[-_](?:live|test|ant|proj)?[-_]?[A-Za-z0-9_-]{20,}\b/],
|
|
14
|
+
['google-api-key', /\bAIza[0-9A-Za-z_-]{35}\b/],
|
|
15
|
+
['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/],
|
|
16
|
+
['url-with-password', /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]{3,}@[^\s/]+/i],
|
|
17
|
+
['assigned-secret', /\b(?:api[_-]?key|secret|token|passwd|password)\b["']?\s*[:=]\s*["']?[A-Za-z0-9/+_.\-]{16,}/i],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** @returns {string[]} names of the rules that matched; empty when none did. */
|
|
21
|
+
function scan(text) {
|
|
22
|
+
const t = String(text == null ? '' : text);
|
|
23
|
+
const hits = [];
|
|
24
|
+
for (const [name, re] of RULES) if (re.test(t)) hits.push(name);
|
|
25
|
+
return hits;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { scan, RULES: RULES.map(([n]) => n) };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* session-budget.js — how many sessions the workspace may start on the PM's behalf.
|
|
3
|
+
*
|
|
4
|
+
* Only sessions started from a next step (origin `next-step`) are counted and limited. A session the PM starts by hand
|
|
5
|
+
* is never counted and never blocked: the budget protects against the workspace spending, not against the PM working.
|
|
6
|
+
* Pure: the caller passes what is running and what started today.
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULTS = Object.freeze({ concurrent: 3, perDay: 8 });
|
|
9
|
+
const RANGE = { concurrent: [1, 20], perDay: [1, 100] };
|
|
10
|
+
|
|
11
|
+
/** @returns {{ok:true,limits}|{ok:false,error}} */
|
|
12
|
+
function normalizeLimits(v) {
|
|
13
|
+
const raw = v && typeof v === 'object' ? v : {};
|
|
14
|
+
const out = { ...DEFAULTS };
|
|
15
|
+
for (const k of ['concurrent', 'perDay']) {
|
|
16
|
+
if (raw[k] === undefined) continue;
|
|
17
|
+
const n = Number(raw[k]);
|
|
18
|
+
if (!Number.isInteger(n) || n < RANGE[k][0] || n > RANGE[k][1]) return { ok: false, error: `${k === 'perDay' ? 'per day' : k} must be a whole number from ${RANGE[k][0]} to ${RANGE[k][1]}` };
|
|
19
|
+
out[k] = n;
|
|
20
|
+
}
|
|
21
|
+
return { ok: true, limits: out };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @param {{running:number, startedToday:number, limits?:object}} s */
|
|
25
|
+
function check(s) {
|
|
26
|
+
const n = normalizeLimits(s && s.limits);
|
|
27
|
+
const limits = n.ok ? n.limits : { ...DEFAULTS };
|
|
28
|
+
const running = Math.max(0, Number(s && s.running) || 0);
|
|
29
|
+
const startedToday = Math.max(0, Number(s && s.startedToday) || 0);
|
|
30
|
+
if (running >= limits.concurrent) return { allowed: false, reason: `${running} prepared sessions are already running (limit ${limits.concurrent}). End one or raise the limit.`, running, startedToday, limits };
|
|
31
|
+
if (startedToday >= limits.perDay) return { allowed: false, reason: `${startedToday} sessions were started today (limit ${limits.perDay}). Raise the limit to start more.`, running, startedToday, limits };
|
|
32
|
+
return { allowed: true, reason: null, running, startedToday, limits };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { DEFAULTS, normalizeLimits, check };
|
|
@@ -18,17 +18,60 @@
|
|
|
18
18
|
|
|
19
19
|
// In the order the PM reads them: what needs them, what is moving, what has not started, what waits on
|
|
20
20
|
// their acceptance, what is done, then what is parked. `active` marks the four that are live work.
|
|
21
|
+
// Every group states five things, so the legend on the screen and the grouping cannot disagree:
|
|
22
|
+
// meaning what the PM is being told
|
|
23
|
+
// source the fact it is read from (a ledger line or git), never a typed field
|
|
24
|
+
// owner who has to act: pm | agent | system
|
|
25
|
+
// moves what takes an initiative out of this group, and where it goes
|
|
26
|
+
// pmCan what the PM can do by hand here (only hold, close, cancel and reopen are written; the rest is derived)
|
|
21
27
|
const GROUPS = [
|
|
22
|
-
{ key: 'needs-you', label: 'Needs you', active: true, hint: 'returned at acceptance, or has open items in the needs queue'
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{ key: '
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
{ key: 'needs-you', label: 'Needs you', active: true, hint: 'returned at acceptance, or has open items in the needs queue',
|
|
29
|
+
meaning: 'Work is stopped on a decision or an answer only you can give.',
|
|
30
|
+
source: 'acceptances.jsonl (your latest acceptance row is a return) or the needs queue (an open item names this initiative)',
|
|
31
|
+
owner: 'pm', moves: 'When the open items are cleared, or you accept after the agent addresses the return, it moves to In progress or Awaiting acceptance.',
|
|
32
|
+
pmCan: 'Answer the items listed in the detail. You can also hold, close or cancel it.' },
|
|
33
|
+
{ key: 'in-progress', label: 'In progress', active: true, hint: 'runs logged, nothing merged yet',
|
|
34
|
+
meaning: 'An agent run is logged and nothing has been merged yet.',
|
|
35
|
+
source: 'agent-runs.jsonl has at least one run for it, and no initiative/<id> branch is merged',
|
|
36
|
+
owner: 'agent', moves: 'When its branch is merged it moves to Awaiting acceptance. An open needs item moves it to Needs you.',
|
|
37
|
+
pmCan: 'Read the next steps and the conversations. You can hold, close or cancel it.' },
|
|
38
|
+
{ key: 'planning', label: 'Planning', active: true, hint: 'recorded, no run logged yet',
|
|
39
|
+
meaning: 'The initiative is recorded and no agent run has started.',
|
|
40
|
+
source: 'the initiative record exists; agent-runs.jsonl has no run for it',
|
|
41
|
+
owner: 'agent', moves: 'When the first run is logged it moves to In progress.',
|
|
42
|
+
pmCan: 'Review what the agent drafts. You can also hold, close or cancel it.' },
|
|
43
|
+
{ key: 'awaiting-acceptance', label: 'Awaiting acceptance', active: true, hint: 'delivered; the PM has not accepted it',
|
|
44
|
+
meaning: 'The work is delivered and waits for your acceptance. Acceptance is yours alone (D12).',
|
|
45
|
+
source: 'git: an initiative/<id> branch was merged, or a trunk commit names the initiative and changes build files; no acceptance row yet',
|
|
46
|
+
owner: 'pm', moves: 'When you record an acceptance it moves to Accepted. If you return it, it moves to Needs you.',
|
|
47
|
+
pmCan: 'Open the Acceptance Gate and accept or return it. You can also hold, close or cancel it.' },
|
|
48
|
+
{ key: 'accepted', label: 'Accepted', active: false, hint: 'accepted by the PM',
|
|
49
|
+
meaning: 'You accepted it. Nothing remains.',
|
|
50
|
+
source: 'acceptances.jsonl (your latest acceptance row accepts)',
|
|
51
|
+
owner: 'system', moves: 'It stays here.',
|
|
52
|
+
pmCan: 'Nothing to do. Close it only if you want it off the board.' },
|
|
53
|
+
{ key: 'on-hold', label: 'On hold', active: false, hint: 'paused by the PM; off the board, not gating',
|
|
54
|
+
meaning: 'You paused it. It is off the board and does not block anything.',
|
|
55
|
+
source: 'lifecycle.jsonl (your latest line for it is on-hold)',
|
|
56
|
+
owner: 'pm', moves: 'It stays here until you reopen it. It then returns to the group its facts give.',
|
|
57
|
+
pmCan: 'Reopen it.' },
|
|
58
|
+
{ key: 'closed', label: 'Closed', active: false, hint: 'closed by the PM; off the board, not gating',
|
|
59
|
+
meaning: 'You closed it. It is off the board and does not block anything.',
|
|
60
|
+
source: 'lifecycle.jsonl (your latest line for it is closed)',
|
|
61
|
+
owner: 'pm', moves: 'It stays here until you reopen it.',
|
|
62
|
+
pmCan: 'Reopen it.' },
|
|
63
|
+
{ key: 'cancelled', label: 'Cancelled', active: false, hint: 'cancelled by the PM; off the board, not gating',
|
|
64
|
+
meaning: 'You cancelled it. It is off the board and does not block anything.',
|
|
65
|
+
source: 'lifecycle.jsonl (your latest line for it is cancelled)',
|
|
66
|
+
owner: 'pm', moves: 'It stays here until you reopen it.',
|
|
67
|
+
pmCan: 'Reopen it.' },
|
|
68
|
+
{ key: 'unknown', label: 'Unknown', active: false, hint: 'the history could not be read, so the status cannot be derived',
|
|
69
|
+
meaning: 'The status could not be worked out, so none is shown.',
|
|
70
|
+
source: 'the trunk history or the ledgers could not be read',
|
|
71
|
+
owner: 'system', moves: 'When the history can be read again it moves to the group its facts give.',
|
|
72
|
+
pmCan: 'Check that the repository is readable, then reload.' },
|
|
31
73
|
];
|
|
74
|
+
const LEGEND_FIELDS = ['meaning', 'source', 'owner', 'moves', 'pmCan'];
|
|
32
75
|
const BY_KEY = Object.fromEntries(GROUPS.map((g) => [g.key, g]));
|
|
33
76
|
|
|
34
77
|
/** The group key for one derived status row `{status, lifecycle}`; `unknown` when the row is missing or unrecognised. */
|
|
@@ -62,5 +105,5 @@
|
|
|
62
105
|
.map((g) => ({ ...g, slugs: buckets.get(g.key) }));
|
|
63
106
|
}
|
|
64
107
|
|
|
65
|
-
return { GROUPS, BY_KEY, keyOf, group };
|
|
108
|
+
return { GROUPS, BY_KEY, LEGEND_FIELDS, keyOf, group };
|
|
66
109
|
}));
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* typesafe-settings.js — where the TypeSafe key and the per-repository consent live (D79).
|
|
3
|
+
*
|
|
4
|
+
* The file is `<runtime state>/typesafe.json`, in the per-user runtime directory that lib/state-root.js
|
|
5
|
+
* keeps OUTSIDE every repository, so no commit, sync or share can carry it. It is written with mode 0600
|
|
6
|
+
* through a temporary file and a rename, and a symlink at the target is refused.
|
|
7
|
+
*
|
|
8
|
+
* What leaves this module: `status()` says whether a key is present and where it came from. It never
|
|
9
|
+
* returns the key. `getKey()` is for the server's validation path only; no route returns its value.
|
|
10
|
+
* `TYPESAFE_API_KEY` in the environment is read as a fallback, ONCE, at server start (`adoptEnv`), which
|
|
11
|
+
* then deletes the variable from the process environment. Nothing the server spawns afterwards can see it.
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { validKey } = require('./typesafe');
|
|
16
|
+
|
|
17
|
+
const FILE = 'typesafe.json';
|
|
18
|
+
const ENV_NAME = 'TYPESAFE_API_KEY';
|
|
19
|
+
const MAX_EXCLUDES = 100;
|
|
20
|
+
|
|
21
|
+
function file(stateDir) { return path.join(stateDir, FILE); }
|
|
22
|
+
|
|
23
|
+
function read(stateDir) {
|
|
24
|
+
try {
|
|
25
|
+
const st = fs.lstatSync(file(stateDir));
|
|
26
|
+
if (!st.isFile()) return { key: null, repos: {} };
|
|
27
|
+
const o = JSON.parse(fs.readFileSync(file(stateDir), 'utf8'));
|
|
28
|
+
return {
|
|
29
|
+
key: typeof o.key === 'string' ? o.key : null,
|
|
30
|
+
repos: o && typeof o.repos === 'object' && o.repos && !Array.isArray(o.repos) ? o.repos : {},
|
|
31
|
+
};
|
|
32
|
+
} catch { return { key: null, repos: {} }; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function write(stateDir, data) {
|
|
36
|
+
const target = file(stateDir);
|
|
37
|
+
try { if (fs.lstatSync(target).isSymbolicLink()) return { ok: false, error: 'the settings file is a symlink and was not written' }; }
|
|
38
|
+
catch { /* not there yet */ }
|
|
39
|
+
const tmp = target + '.' + process.pid + '.tmp';
|
|
40
|
+
try {
|
|
41
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
42
|
+
fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
43
|
+
fs.chmodSync(tmp, 0o600);
|
|
44
|
+
fs.renameSync(tmp, target);
|
|
45
|
+
return { ok: true };
|
|
46
|
+
} catch (e) {
|
|
47
|
+
try { fs.unlinkSync(tmp); } catch { /* nothing to clean */ }
|
|
48
|
+
return { ok: false, error: 'the settings could not be saved (' + String((e && e.code) || 'write failed') + ')' };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* The environment key is TAKEN, not borrowed. `adoptEnv` moves it from the process environment into this
|
|
53
|
+
* module's memory and deletes the variable, so no child the server starts afterwards — a terminal, git, a
|
|
54
|
+
* schedule — inherits it. Called once at server start. */
|
|
55
|
+
let adopted = null;
|
|
56
|
+
function adoptEnv(env) {
|
|
57
|
+
if (env && validKey(env[ENV_NAME])) adopted = env[ENV_NAME];
|
|
58
|
+
if (env) delete env[ENV_NAME];
|
|
59
|
+
}
|
|
60
|
+
function envKey(env) { return adopted || (env && validKey(env[ENV_NAME]) ? env[ENV_NAME] : null); }
|
|
61
|
+
|
|
62
|
+
/** The key for the server's own use, or null. File first, then the environment. */
|
|
63
|
+
function getKey(stateDir, env = null) {
|
|
64
|
+
const f = read(stateDir).key;
|
|
65
|
+
if (validKey(f)) return f;
|
|
66
|
+
return envKey(env);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What the browser may know: present or not, and from where. Never the value, never its length. */
|
|
70
|
+
function status(stateDir, env = null) {
|
|
71
|
+
if (validKey(read(stateDir).key)) return { present: true, source: 'settings' };
|
|
72
|
+
if (envKey(env)) return { present: true, source: 'environment' };
|
|
73
|
+
return { present: false, source: null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function setKey(stateDir, key) {
|
|
77
|
+
const k = typeof key === 'string' ? key.trim() : '';
|
|
78
|
+
if (!validKey(k)) return { ok: false, error: 'that does not look like a key — it must be 8 to 512 characters with no spaces' };
|
|
79
|
+
const cur = read(stateDir);
|
|
80
|
+
return write(stateDir, { key: k, repos: cur.repos });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clearKey(stateDir) {
|
|
84
|
+
const cur = read(stateDir);
|
|
85
|
+
return write(stateDir, { key: null, repos: cur.repos });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Consent and exclusions for one repository root. Consent defaults to off. */
|
|
89
|
+
function getRepo(stateDir, root) {
|
|
90
|
+
const r = read(stateDir).repos[root];
|
|
91
|
+
return {
|
|
92
|
+
consent: !!(r && r.consent === true),
|
|
93
|
+
exclude: r && Array.isArray(r.exclude) ? r.exclude.filter((x) => typeof x === 'string') : [],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function setRepo(stateDir, root, { consent, exclude }) {
|
|
98
|
+
const cur = read(stateDir);
|
|
99
|
+
const prev = getRepo(stateDir, root);
|
|
100
|
+
const ex = Array.isArray(exclude)
|
|
101
|
+
? exclude.map((x) => String(x).trim().replace(/^\/+/, '')).filter((x) => x && !x.includes('..') && x.length <= 200).slice(0, MAX_EXCLUDES)
|
|
102
|
+
: prev.exclude;
|
|
103
|
+
const repos = { ...cur.repos, [root]: { consent: typeof consent === 'boolean' ? consent : prev.consent, exclude: ex } };
|
|
104
|
+
return write(stateDir, { key: cur.key, repos });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Does `rel` fall under an exclusion? An entry is a path prefix, or a `*` pattern matched on the path. */
|
|
108
|
+
function isExcluded(exclude, rel) {
|
|
109
|
+
const p = String(rel).replace(/\\/g, '/');
|
|
110
|
+
return exclude.some((e) => {
|
|
111
|
+
if (e.includes('*')) {
|
|
112
|
+
const re = new RegExp('^' + e.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$');
|
|
113
|
+
return re.test(p);
|
|
114
|
+
}
|
|
115
|
+
return p === e || p.startsWith(e.endsWith('/') ? e : e + '/');
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { adoptEnv, status, getKey, setKey, clearKey, getRepo, setRepo, isExcluded, ENV_NAME, FILE };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* typesafe.js — the ONE place the workspace server makes an outbound request (D79).
|
|
3
|
+
*
|
|
4
|
+
* D69, D70 and D71 say the PMOS server makes no outbound request. D79 narrows that by one named
|
|
5
|
+
* exception: HTTPS to `api.typesafe.ai`, from this module, and nowhere else. Three properties keep the
|
|
6
|
+
* exception small, and each has a test:
|
|
7
|
+
*
|
|
8
|
+
* 1. ONE HOST, FIXED IN SOURCE. The host is a constant. No argument, setting or environment variable
|
|
9
|
+
* names it. A response that redirects is REFUSED, not followed, because a redirect is how a fixed
|
|
10
|
+
* host becomes another host.
|
|
11
|
+
* 2. THE KEY LEAVES THIS PROCESS IN ONE HEADER. It is an argument to `ask`, never stored here, never
|
|
12
|
+
* placed in an error, a log line or a returned object.
|
|
13
|
+
* 3. NO KEY, NO REQUEST. `ask` refuses before opening a socket when the key is absent.
|
|
14
|
+
*
|
|
15
|
+
* What Jev is: a classifier. The caller sends text (`state`) and typed questions; it returns answers
|
|
16
|
+
* with probabilities. It reads no files and takes no action — the caller reads the files.
|
|
17
|
+
* Noul "high means yes"; the single `noul` number is P(yes). There is no separate confidence.
|
|
18
|
+
* Score levels 0..N-1; `score` is the probability-weighted mean; `confidence` is reported.
|
|
19
|
+
* Choice one option plus a distribution.
|
|
20
|
+
*/
|
|
21
|
+
const https = require('https');
|
|
22
|
+
const http = require('http');
|
|
23
|
+
|
|
24
|
+
const HOST = 'api.typesafe.ai';
|
|
25
|
+
const PATH = '/v1/systemone';
|
|
26
|
+
const DEFAULT_MODEL = 'jev-latest';
|
|
27
|
+
const MAX_STATE_CHARS = 60000; // a conservative cap; the documentation states no limit
|
|
28
|
+
const MAX_RESPONSE_BYTES = 1 << 20; // 1 MiB — an answer set is small; anything larger is not one
|
|
29
|
+
const MAX_QUESTIONS = 16;
|
|
30
|
+
|
|
31
|
+
/* A seam for tests, gated like the sink's. It lets a suite point this module at a loopback stub; it
|
|
32
|
+
* is unreachable in a production process, so no setting can redirect the key to another host. */
|
|
33
|
+
let endpointForTests = null;
|
|
34
|
+
function __setEndpointForTests(e) {
|
|
35
|
+
const on = (v) => v === '1' || v === 'true' || v === 'yes';
|
|
36
|
+
if (process.env.NODE_ENV !== 'test' && !on(process.env.PMOS_TEST)) {
|
|
37
|
+
throw new Error('__setEndpointForTests is a test seam — set NODE_ENV=test or PMOS_TEST=1 to use it');
|
|
38
|
+
}
|
|
39
|
+
endpointForTests = e || null;
|
|
40
|
+
}
|
|
41
|
+
function endpoint() {
|
|
42
|
+
return endpointForTests || { protocol: 'https:', hostname: HOST, port: 443 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
46
|
+
const retryable = (status) => status === 429 || status === 529 || (status >= 500 && status < 600);
|
|
47
|
+
|
|
48
|
+
function validKey(key) {
|
|
49
|
+
return typeof key === 'string' && key.length >= 8 && key.length <= 512 && !/[\s\u0000-\u001f\u007f]/.test(key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function once({ key, body, timeoutMs }) {
|
|
53
|
+
const ep = endpoint();
|
|
54
|
+
const mod = ep.protocol === 'http:' ? http : https;
|
|
55
|
+
const payload = Buffer.from(body, 'utf8');
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
let done = false;
|
|
58
|
+
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
59
|
+
const req = mod.request({
|
|
60
|
+
protocol: ep.protocol, hostname: ep.hostname, port: ep.port, path: PATH, method: 'POST',
|
|
61
|
+
headers: {
|
|
62
|
+
'Content-Type': 'application/json', 'Content-Length': payload.length,
|
|
63
|
+
Authorization: 'Bearer ' + key, 'User-Agent': 'pmos-workspace',
|
|
64
|
+
},
|
|
65
|
+
timeout: timeoutMs,
|
|
66
|
+
}, (res) => {
|
|
67
|
+
const status = res.statusCode || 0;
|
|
68
|
+
// Refused, not followed: the host is fixed in source and a redirect would change it.
|
|
69
|
+
if (status >= 300 && status < 400) { res.resume(); return finish({ ok: false, status, reason: 'the service sent a redirect, which is refused' }); }
|
|
70
|
+
const chunks = []; let size = 0;
|
|
71
|
+
res.on('data', (c) => {
|
|
72
|
+
size += c.length;
|
|
73
|
+
if (size > MAX_RESPONSE_BYTES) { finish({ ok: false, status, reason: 'the response was larger than expected and was dropped' }); req.destroy(); return; }
|
|
74
|
+
chunks.push(c);
|
|
75
|
+
});
|
|
76
|
+
res.on('end', () => {
|
|
77
|
+
let json = null;
|
|
78
|
+
try { json = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* not JSON */ }
|
|
79
|
+
if (status >= 200 && status < 300 && json && typeof json.answers === 'object' && json.answers) {
|
|
80
|
+
return finish({ ok: true, status, model: String(json.model || ''), answers: json.answers, usage: json.usage || null });
|
|
81
|
+
}
|
|
82
|
+
const why = status === 401 ? 'the service rejected the key (401)'
|
|
83
|
+
: status === 422 ? 'the service rejected the request as malformed (422)'
|
|
84
|
+
: status === 429 ? 'the service rate-limited the request (429)'
|
|
85
|
+
: status === 529 ? 'the service is overloaded (529)'
|
|
86
|
+
: `the service answered ${status || 'with something unreadable'}`;
|
|
87
|
+
finish({ ok: false, status, reason: why });
|
|
88
|
+
});
|
|
89
|
+
res.on('error', () => finish({ ok: false, status, reason: 'the connection broke while reading the answer' }));
|
|
90
|
+
});
|
|
91
|
+
req.on('timeout', () => { finish({ ok: false, status: 0, reason: 'the service did not answer in time' }); req.destroy(); });
|
|
92
|
+
req.on('error', (e) => finish({ ok: false, status: 0, reason: 'the service could not be reached (' + String((e && e.code) || 'network error') + ')' }));
|
|
93
|
+
req.end(payload);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Ask Jev typed questions about `state`.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} a
|
|
101
|
+
* @param {string} a.key the API key. Required; no key means no request.
|
|
102
|
+
* @param {string} a.state the text to judge. Refused above MAX_STATE_CHARS.
|
|
103
|
+
* @param {object} a.questions { name: { type: 'noul'|'score'|'choice', instructions, criteria? } }
|
|
104
|
+
* @returns {Promise<{ok:true, model, answers, usage}|{ok:false, reason, status?, requested:boolean}>}
|
|
105
|
+
* `requested` says whether a request left this process, so a caller can tell a refusal from a failure.
|
|
106
|
+
*/
|
|
107
|
+
async function ask({ key, state, questions, model = DEFAULT_MODEL, timeoutMs = 30000, retries = 2, backoffMs = 600 } = {}) {
|
|
108
|
+
if (!validKey(key)) return { ok: false, requested: false, reason: 'no TypeSafe key is set' };
|
|
109
|
+
if (typeof state !== 'string' || !state.trim()) return { ok: false, requested: false, reason: 'there is no text to judge' };
|
|
110
|
+
if (state.length > MAX_STATE_CHARS) return { ok: false, requested: false, reason: `the text is longer than ${MAX_STATE_CHARS} characters` };
|
|
111
|
+
const names = questions && typeof questions === 'object' ? Object.keys(questions) : [];
|
|
112
|
+
if (!names.length || names.length > MAX_QUESTIONS) return { ok: false, requested: false, reason: `between 1 and ${MAX_QUESTIONS} questions are required` };
|
|
113
|
+
const body = JSON.stringify({ state, model, questions });
|
|
114
|
+
let last = null;
|
|
115
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
116
|
+
last = await once({ key, body, timeoutMs });
|
|
117
|
+
if (last.ok) return { ...last, requested: true };
|
|
118
|
+
if (!(last.status === 0 || retryable(last.status)) || attempt === retries) break;
|
|
119
|
+
await sleep(backoffMs * Math.pow(2, attempt));
|
|
120
|
+
}
|
|
121
|
+
return { ok: false, requested: true, status: last.status, reason: last.reason };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { ask, validKey, HOST, PATH, DEFAULT_MODEL, MAX_STATE_CHARS, MAX_QUESTIONS, __setEndpointForTests };
|