crewforth 3.5.0-rc.1 → 3.5.0-rc.2
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/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/VERSION +1 -1
- package/kit/CLAUDE.md +1 -1
- package/kit/eval/lib/statusline.sh +22 -5
- package/kit/eval/lib/todo.awk +167 -0
- package/kit/eval/lib/todo.sh +120 -0
- package/kit/eval/smoke-test.sh +60 -2
- package/kit/hooks/prompt-approval.sh +5 -5
- package/kit/skills/crew-plan/SKILL.md +2 -1
- package/kit/skills/crew-todo/SKILL.md +31 -0
- package/kit/skills/spec-planning/SKILL.md +9 -2
- package/kit/studio/web/agent-spend.js +32 -0
- package/kit/studio/web/app.js +245 -236
- package/kit/studio/web/approvals.js +14 -13
- package/kit/studio/web/ask.js +10 -9
- package/kit/studio/web/canvas.js +56 -54
- package/kit/studio/web/chat.js +41 -41
- package/kit/studio/web/convo.js +21 -18
- package/kit/studio/web/dock.js +20 -19
- package/kit/studio/web/graph-plan.js +62 -22
- package/kit/studio/web/i18n-tr.js +760 -0
- package/kit/studio/web/i18n.js +79 -0
- package/kit/studio/web/index.html +1 -0
- package/kit/studio/web/inspect.js +27 -24
- package/kit/studio/web/list-plan.js +16 -15
- package/kit/studio/web/list.js +6 -5
- package/kit/studio/web/liveness.js +11 -9
- package/kit/studio/web/map-plan.js +33 -29
- package/kit/studio/web/map.js +74 -73
- package/kit/studio/web/models-plan.js +47 -42
- package/kit/studio/web/models.js +21 -20
- package/kit/studio/web/names.js +13 -10
- package/kit/studio/web/nav.js +34 -33
- package/kit/studio/web/newsession.js +25 -24
- package/kit/studio/web/style.css +2 -0
- package/kit/studio/web/theme.js +5 -2
- package/kit/studio/web/timeline-plan.js +19 -18
- package/kit/studio/web/timeline.js +23 -22
- package/kit/studio/web/usage.js +36 -32
- package/package.json +1 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The panel's language. English is the panel's own and the default; Turkish is a choice the viewer makes.
|
|
2
|
+
//
|
|
3
|
+
// The English sentence is the key. t() returns it unchanged unless Turkish is chosen and the dictionary holds it,
|
|
4
|
+
// so a sentence nobody has translated yet still reads, in English, rather than as a key or a blank. Whether every
|
|
5
|
+
// sentence HAS a translation is not left to that fallback: the Studio gate reads each t('…') in web/ and index.html
|
|
6
|
+
// and fails on a missing, extra or mismatched entry.
|
|
7
|
+
//
|
|
8
|
+
// The language is read once, when this module is first evaluated. Every module that renders text imports this one,
|
|
9
|
+
// so it is settled before any of them builds a label, including the word maps they build at load. Switching reloads
|
|
10
|
+
// the page for that reason: a label built in one language is not rebuilt in the other by anything less.
|
|
11
|
+
import { TR } from './i18n-tr.js';
|
|
12
|
+
|
|
13
|
+
export const LANG_KEY = 'crewforth-studio-lang';
|
|
14
|
+
const known = (v) => (v === 'en' || v === 'tr' ? v : null);
|
|
15
|
+
|
|
16
|
+
function saved() {
|
|
17
|
+
// Through window, not globalThis: Node answers a bare localStorage with a warning, and the gate imports this.
|
|
18
|
+
try { return known(globalThis.window?.localStorage?.getItem(LANG_KEY)); } catch { return null; /* blocked storage: English */ }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let lang = saved() ?? 'en';
|
|
22
|
+
|
|
23
|
+
export const currentLang = () => lang;
|
|
24
|
+
/** For the gate: the page itself switches by reloading, never by this. */
|
|
25
|
+
export function setLang(l) { lang = known(l) ?? 'en'; }
|
|
26
|
+
|
|
27
|
+
/** The sentence in the chosen language, with {name} filled from vars. */
|
|
28
|
+
export function t(en, vars) {
|
|
29
|
+
const s = lang === 'tr' && Object.hasOwn(TR, en) ? TR[en] : en;
|
|
30
|
+
return vars ? s.replace(/\{(\w+)\}/g, (m, k) => (Object.hasOwn(vars, k) ? String(vars[k]) : m)) : s;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A sentence with elements in it ("Runs in <code>…</code> on branch <code>…</code>."): the translated sentence cut at
|
|
35
|
+
* its {slots}, each slot replaced by the node given for it, so the words around them can move with the language.
|
|
36
|
+
*/
|
|
37
|
+
export function tParts(en, slots) {
|
|
38
|
+
return t(en).split(/(\{\w+\})/).filter(Boolean)
|
|
39
|
+
.map((p) => { const m = /^\{(\w+)\}$/.exec(p); return m && Object.hasOwn(slots, m[1]) ? slots[m[1]] : p; });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The locale numbers and clock times are written in. */
|
|
43
|
+
export const numLocale = () => (lang === 'tr' ? 'tr-TR' : 'en-US');
|
|
44
|
+
|
|
45
|
+
const ATTRS = ['aria-label', 'title', 'placeholder'];
|
|
46
|
+
|
|
47
|
+
/** index.html is written in English; this puts its words in the chosen language. A no-op in English. */
|
|
48
|
+
export function translateDom(root) {
|
|
49
|
+
root.ownerDocument?.documentElement?.setAttribute('lang', lang);
|
|
50
|
+
if (lang === 'en') return;
|
|
51
|
+
for (const e of root.querySelectorAll('*')) {
|
|
52
|
+
for (const a of ATTRS) {
|
|
53
|
+
const v = e.getAttribute(a);
|
|
54
|
+
if (v && Object.hasOwn(TR, v)) e.setAttribute(a, TR[v]);
|
|
55
|
+
}
|
|
56
|
+
for (const n of e.childNodes) {
|
|
57
|
+
if (n.nodeType !== 3) continue;
|
|
58
|
+
const s = n.nodeValue.trim();
|
|
59
|
+
if (s && Object.hasOwn(TR, s)) n.nodeValue = n.nodeValue.replace(s, TR[s]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The language button. It names the language it switches TO, in that language, so a viewer who cannot read the
|
|
66
|
+
* current one can still find the way back. The choice is remembered per browser; storage that refuses costs the
|
|
67
|
+
* memory, and the page then reloads into the default.
|
|
68
|
+
*/
|
|
69
|
+
export function initLang(button, ls, reload) {
|
|
70
|
+
const to = lang === 'tr' ? 'en' : 'tr';
|
|
71
|
+
button.textContent = to.toUpperCase();
|
|
72
|
+
const say = to === 'tr' ? 'Türkçe' : 'English';
|
|
73
|
+
button.setAttribute('aria-label', say);
|
|
74
|
+
button.title = say;
|
|
75
|
+
button.addEventListener('click', () => {
|
|
76
|
+
try { ls.setItem(LANG_KEY, to); } catch { /* blocked storage: nothing to remember it in */ }
|
|
77
|
+
reload();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
<button id="theme" class="btn icon ghost" type="button" aria-label="Switch theme">
|
|
31
31
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M13 9.5A5.5 5.5 0 0 1 6.5 3a5.5 5.5 0 1 0 6.5 6.5z"/></svg>
|
|
32
32
|
</button>
|
|
33
|
+
<button id="lang" class="btn icon ghost lang-btn" type="button" aria-label="Switch language"></button>
|
|
33
34
|
<button id="fullscreen" class="btn icon ghost" type="button" title="Full screen (f)" aria-label="Full screen">
|
|
34
35
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 6V3h3M10 3h3v3M13 10v3h-3M6 13H3v-3"/></svg>
|
|
35
36
|
</button>
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// skills it applied, who delegated to it. The rule they share is the panel's own: a value that was not read is
|
|
5
5
|
// written "Not measured", with why — never as 0 and never as an empty list.
|
|
6
6
|
import { roleName, modelName, modelFamily, modelOf } from './names.js';
|
|
7
|
+
import { t } from './i18n.js';
|
|
7
8
|
|
|
8
|
-
const NOT_MEASURED = 'Not measured';
|
|
9
|
+
const NOT_MEASURED = t('Not measured');
|
|
9
10
|
|
|
10
11
|
export function fmtTokens(t) {
|
|
11
12
|
if (t == null) return null;
|
|
@@ -17,9 +18,9 @@ export function fmtTokens(t) {
|
|
|
17
18
|
export function fmtDuration(ms) {
|
|
18
19
|
if (ms == null || !Number.isFinite(ms)) return null;
|
|
19
20
|
const s = Math.max(0, Math.round(ms / 1000));
|
|
20
|
-
if (s < 60) return
|
|
21
|
+
if (s < 60) return t('{n}s', { n: s });
|
|
21
22
|
const m = Math.floor(s / 60);
|
|
22
|
-
return m < 60 ?
|
|
23
|
+
return m < 60 ? t('{m}m {s}s', { m, s: s % 60 }) : t('{h}h {m}m', { h: Math.floor(m / 60), m: m % 60 });
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/** How long the agent has been at it: the recorded duration when it has ended, the clock since it started while
|
|
@@ -37,21 +38,21 @@ export function elapsed(n, nowMs) {
|
|
|
37
38
|
export function tiles(n, nowMs) {
|
|
38
39
|
const el = elapsed(n, nowMs);
|
|
39
40
|
return [
|
|
40
|
-
{ label: 'Tool calls', value: String(n.toolCount ?? 0), measured: n.toolCount != null },
|
|
41
|
+
{ label: t('Tool calls'), value: String(n.toolCount ?? 0), measured: n.toolCount != null },
|
|
41
42
|
{
|
|
42
|
-
label: 'Tokens',
|
|
43
|
+
label: t('Tokens'),
|
|
43
44
|
value: n.tokens == null ? NOT_MEASURED : fmtTokens(n.tokens),
|
|
44
45
|
measured: n.tokens != null,
|
|
45
|
-
why: n.tokens == null ? 'the transcript carries no usage record for this agent yet' : null,
|
|
46
|
+
why: n.tokens == null ? t('the transcript carries no usage record for this agent yet') : null,
|
|
46
47
|
},
|
|
47
48
|
{
|
|
48
|
-
label: 'Elapsed',
|
|
49
|
+
label: t('Elapsed'),
|
|
49
50
|
value: el == null ? NOT_MEASURED : fmtDuration(el),
|
|
50
51
|
measured: el != null,
|
|
51
|
-
why: el == null ? 'no start time was recorded' : null,
|
|
52
|
+
why: el == null ? t('no start time was recorded') : null,
|
|
52
53
|
},
|
|
53
|
-
{ label: 'Errors', value: String(n.errors ?? 0), measured: n.errors != null, bad: (n.errors ?? 0) > 0 },
|
|
54
|
-
].map((
|
|
54
|
+
{ label: t('Errors'), value: String(n.errors ?? 0), measured: n.errors != null, bad: (n.errors ?? 0) > 0 },
|
|
55
|
+
].map((x) => (x.measured ? x : { ...x, value: NOT_MEASURED, why: x.why ?? t('not in the transcript') }));
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/** "8m 20s · started 14:02" */
|
|
@@ -61,7 +62,7 @@ export function timeLine(n, nowMs) {
|
|
|
61
62
|
if (el != null) parts.push(fmtDuration(el));
|
|
62
63
|
if (n.startedAt != null) {
|
|
63
64
|
const d = new Date(n.startedAt);
|
|
64
|
-
parts.push(
|
|
65
|
+
parts.push(t('started {time}', { time: `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` }));
|
|
65
66
|
}
|
|
66
67
|
return parts.join(' · ');
|
|
67
68
|
}
|
|
@@ -74,8 +75,8 @@ export function rightNow(n, detail) {
|
|
|
74
75
|
const live = n.status === 'running' || n.status === 'starting';
|
|
75
76
|
const last = detail?.timeline?.length ? detail.timeline[detail.timeline.length - 1] : null;
|
|
76
77
|
const tool = last?.name ?? n.lastTool ?? null;
|
|
77
|
-
if (!tool) return { heading: live ? 'Right now' : 'Last tool', tool: null, text: live ? 'No tool call yet' : 'No tool was called' };
|
|
78
|
-
return { heading: live ? 'Right now' : 'Last tool', tool, text: last?.label ?? '' };
|
|
78
|
+
if (!tool) return { heading: live ? t('Right now') : t('Last tool'), tool: null, text: live ? t('No tool call yet') : t('No tool was called') };
|
|
79
|
+
return { heading: live ? t('Right now') : t('Last tool'), tool, text: last?.label ?? '' };
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
/** The skills the agent invoked, in the order it first did, each once. */
|
|
@@ -112,23 +113,25 @@ export function modelLines(n, nodes) {
|
|
|
112
113
|
if (n?.kind !== 'agent') return [];
|
|
113
114
|
const m = modelOf(n);
|
|
114
115
|
const lines = [
|
|
115
|
-
{ label: 'Ran on', text: n.model ? `${m.ran} \u00b7 ${n.model}` : 'not recorded' },
|
|
116
|
-
{ label: 'Asked for', text: n.modelAsked ? `${n.modelAsked}${m.differs ?
|
|
116
|
+
{ label: t('Ran on'), text: n.model ? `${m.ran} \u00b7 ${n.model}` : t('not recorded') },
|
|
117
|
+
{ label: t('Asked for'), text: n.modelAsked ? `${n.modelAsked}${m.differs ? ` \u2014 ${t('differs from what ran')}` : ''}` : t('no model named (the agent\'s default)') },
|
|
117
118
|
];
|
|
118
119
|
// The family is the word the choice is made in (haiku, sonnet, opus); a model with none keeps its own name.
|
|
119
|
-
const short = (model) => (model ? (modelFamily(model) ?? modelName(model).toLowerCase()) : 'no model');
|
|
120
|
+
const short = (model) => (model ? (modelFamily(model) ?? modelName(model).toLowerCase()) : t('no model'));
|
|
120
121
|
if (n.firstTry) {
|
|
121
122
|
lines.push({
|
|
122
|
-
label: 'Model gate',
|
|
123
|
-
text:
|
|
123
|
+
label: t('Model gate'),
|
|
124
|
+
text: n.firstTry.floor
|
|
125
|
+
? t('first asked: {asked} \u2192 refused (floor: {floor}) \u2192 {ran}', { asked: short(n.firstTry.asked), floor: n.firstTry.floor, ran: short(n.modelAsked ?? n.model) })
|
|
126
|
+
: t('first asked: {asked} \u2192 refused \u2192 {ran}', { asked: short(n.firstTry.asked), ran: short(n.modelAsked ?? n.model) }),
|
|
124
127
|
note: n.firstTry.text,
|
|
125
128
|
});
|
|
126
129
|
}
|
|
127
130
|
const tagOf = (id) => { const o = (nodes ?? []).find((x) => x.id === id); return o?.order != null ? ` (#${o.order})` : ''; };
|
|
128
131
|
if (n.escalatedFrom) {
|
|
129
|
-
lines.push({ label: 'Escalated', text:
|
|
132
|
+
lines.push({ label: t('Escalated'), text: t('escalated: {from} \u2192 {to}, after{tag} closed "confidence: low"', { from: short(n.escalatedFrom.from), to: short(n.escalatedFrom.to), tag: tagOf(n.escalatedFrom.id) }), goto: n.escalatedFrom.id });
|
|
130
133
|
}
|
|
131
|
-
if (n.escalatedTo) lines.push({ label: 'Escalated', text:
|
|
134
|
+
if (n.escalatedTo) lines.push({ label: t('Escalated'), text: t('closed "confidence: low"; repeated one model up{tag}', { tag: tagOf(n.escalatedTo) }), goto: n.escalatedTo });
|
|
132
135
|
return lines;
|
|
133
136
|
}
|
|
134
137
|
|
|
@@ -136,8 +139,8 @@ export function modelLines(n, nodes) {
|
|
|
136
139
|
export function delegatedBy(n, nodes) {
|
|
137
140
|
const p = (nodes ?? []).find((x) => x.id === n.parentId) ?? null;
|
|
138
141
|
if (!p) return null;
|
|
139
|
-
if (p.kind === 'session') return { id: p.id, text:
|
|
140
|
-
if (p.kind === 'workflow') return { id: p.id, text:
|
|
142
|
+
if (p.kind === 'session') return { id: p.id, text: `${t('Session')}${p.gitBranch ? ` · ${p.gitBranch}` : ''}` };
|
|
143
|
+
if (p.kind === 'workflow') return { id: p.id, text: `${t('Workflow run')} · ${p.workflowId ?? p.id}` };
|
|
141
144
|
return { id: p.id, text: p.agentType ? roleName(p.agentType) : p.id };
|
|
142
145
|
}
|
|
143
146
|
|
|
@@ -147,9 +150,9 @@ export function reportOf(n, detail) {
|
|
|
147
150
|
const live = n.status === 'running' || n.status === 'starting';
|
|
148
151
|
return {
|
|
149
152
|
present: false,
|
|
150
|
-
text: live ? 'Not reported yet — the agent is still running.' : 'Not reported — the transcript holds no closing text.',
|
|
153
|
+
text: live ? t('Not reported yet — the agent is still running.') : t('Not reported — the transcript holds no closing text.'),
|
|
151
154
|
};
|
|
152
155
|
}
|
|
153
156
|
|
|
154
157
|
export const TABS = ['overview', 'conversation', 'gates', 'stats'];
|
|
155
|
-
export const TAB_WORD = { overview: 'Overview', conversation: 'Conversation', gates: 'Gates', stats: 'Stats' };
|
|
158
|
+
export const TAB_WORD = { overview: t('Overview'), conversation: t('Conversation'), gates: t('Gates'), stats: t('Stats') };
|
|
@@ -5,15 +5,16 @@
|
|
|
5
5
|
// gets a section of its own, under its own name, after them.
|
|
6
6
|
import { stateOf } from './graph-plan.js';
|
|
7
7
|
import { typeOf, shownType } from './names.js';
|
|
8
|
+
import { t } from './i18n.js';
|
|
8
9
|
|
|
9
10
|
/** "8m", "1h 4m", "40s": how long, in as little room as a row has. */
|
|
10
11
|
export function short(ms) {
|
|
11
12
|
if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
|
|
12
13
|
const s = Math.floor(ms / 1000);
|
|
13
|
-
if (s < 60) return
|
|
14
|
+
if (s < 60) return t('{n}s', { n: s });
|
|
14
15
|
const m = Math.floor(s / 60);
|
|
15
|
-
if (m < 60) return
|
|
16
|
-
return
|
|
16
|
+
if (m < 60) return t('{n}m', { n: m });
|
|
17
|
+
return t('{h}h {m}m', { h: Math.floor(m / 60), m: m % 60 });
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
const when = (n) => n.endedAt ?? n.updatedAt ?? n.startedAt ?? 0;
|
|
@@ -23,9 +24,9 @@ function rowOf(n, now) {
|
|
|
23
24
|
const state = stateOf(n.status);
|
|
24
25
|
if (state === 'failed') {
|
|
25
26
|
const bits = [];
|
|
26
|
-
if (n.workflow) bits.push(
|
|
27
|
+
if (n.workflow) bits.push(t('in {name}', { name: n.workflow }));
|
|
27
28
|
// The count the transcript gave. An agent can fail without a tool error; then there is none to report.
|
|
28
|
-
if (n.errors) bits.push(
|
|
29
|
+
if (n.errors) bits.push(n.errors === 1 ? t('{n} error', { n: 1 }) : t('{n} errors', { n: n.errors }));
|
|
29
30
|
if (n.status !== 'failed') bits.push(n.status);
|
|
30
31
|
return { id: n.id, node: n, type: shownType(n), real: typeOf(n), line: bits.join(' · ') || (n.description ?? ''), aside: null };
|
|
31
32
|
}
|
|
@@ -37,7 +38,7 @@ function rowOf(n, now) {
|
|
|
37
38
|
real: typeOf(n),
|
|
38
39
|
line: n.description ?? '',
|
|
39
40
|
// How long it has been going. With no start recorded there is nothing to count from, and nothing is shown.
|
|
40
|
-
aside: state === 'waking' ? 'starting' : live && n.startedAt != null ? short(now - n.startedAt) : null,
|
|
41
|
+
aside: state === 'waking' ? t('starting') : live && n.startedAt != null ? short(now - n.startedAt) : null,
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -56,16 +57,16 @@ export function sections(nodes, { queue = [], current = null, folded = new Map()
|
|
|
56
57
|
|
|
57
58
|
// Needs you: every request waiting, this session's first. Each is a card that carries its own answers.
|
|
58
59
|
const cards = [...queue].sort((a, b) => Number(b.sessionId === current) - Number(a.sessionId === current) || (a.askedAt ?? 0) - (b.askedAt ?? 0));
|
|
59
|
-
if (cards.length) out.push({ key: 'needs', title: 'Needs you', count: cards.length, tone: 'waiting', cards, rows: [], folded: false, foldable: false });
|
|
60
|
+
if (cards.length) out.push({ key: 'needs', title: t('Needs you'), count: cards.length, tone: 'waiting', cards, rows: [], folded: false, foldable: false });
|
|
60
61
|
|
|
61
62
|
const by = (pred, sort) => agents.filter(pred).sort(sort).map((n) => rowOf(n, now));
|
|
62
63
|
const newest = (a, b) => when(b) - when(a);
|
|
63
64
|
const oldest = (a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity) || String(a.id).localeCompare(String(b.id));
|
|
64
65
|
|
|
65
66
|
const fixed = [
|
|
66
|
-
{ key: 'failed', title: 'Failed', tone: 'fail', rows: by((n) => stateOf(n.status) === 'failed', newest), foldedByDefault: false },
|
|
67
|
-
{ key: 'running', title: 'Running', tone: 'busy', rows: by((n) => ['live', 'waking'].includes(stateOf(n.status)), oldest), foldedByDefault: false },
|
|
68
|
-
{ key: 'done', title: 'Done', tone: 'good', rows: by((n) => stateOf(n.status) === 'done', newest), foldedByDefault: true },
|
|
67
|
+
{ key: 'failed', title: t('Failed'), tone: 'fail', rows: by((n) => stateOf(n.status) === 'failed', newest), foldedByDefault: false },
|
|
68
|
+
{ key: 'running', title: t('Running'), tone: 'busy', rows: by((n) => ['live', 'waking'].includes(stateOf(n.status)), oldest), foldedByDefault: false },
|
|
69
|
+
{ key: 'done', title: t('Done'), tone: 'good', rows: by((n) => stateOf(n.status) === 'done', newest), foldedByDefault: true },
|
|
69
70
|
];
|
|
70
71
|
|
|
71
72
|
// Everything else, by the status the transcript wrote. `ended` and `stale` have words here; anything the panel
|
|
@@ -78,7 +79,7 @@ export function sections(nodes, { queue = [], current = null, folded = new Map()
|
|
|
78
79
|
if (!rest.has(key)) rest.set(key, []);
|
|
79
80
|
rest.get(key).push(n);
|
|
80
81
|
}
|
|
81
|
-
const WORD = { ended: 'Ended', stale: 'Stale' };
|
|
82
|
+
const WORD = { ended: t('Ended'), stale: t('Stale') };
|
|
82
83
|
for (const [status, members] of [...rest].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
83
84
|
fixed.push({
|
|
84
85
|
key: `status:${status}`, title: WORD[status] ?? status, tone: 'none', known: status in WORD,
|
|
@@ -100,9 +101,9 @@ export function sections(nodes, { queue = [], current = null, folded = new Map()
|
|
|
100
101
|
|
|
101
102
|
/** What a request card says under the agent's name. */
|
|
102
103
|
export function wants(item) {
|
|
103
|
-
if (item.ask?.kind === 'questions') return 'asks';
|
|
104
|
-
if (item.ask?.kind === 'plan') return 'wants its plan approved';
|
|
105
|
-
return
|
|
104
|
+
if (item.ask?.kind === 'questions') return t('asks');
|
|
105
|
+
if (item.ask?.kind === 'plan') return t('wants its plan approved');
|
|
106
|
+
return t('wants to run {tool}', { tool: item.toolName });
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
/** Which view a window opens on when the viewer has not chosen one: the List on a phone, the graph elsewhere. */
|
|
@@ -113,5 +114,5 @@ export function defaultView(stored, phone) {
|
|
|
113
114
|
|
|
114
115
|
/** The graph and the Timeline are drawn for a wide window. On a phone they still open, and say so. */
|
|
115
116
|
export function narrowWarning(view, phone) {
|
|
116
|
-
return phone && view !== 'list' ? 'Best on a wider screen' : null;
|
|
117
|
+
return phone && view !== 'list' ? t('Best on a wider screen') : null;
|
|
117
118
|
}
|
package/kit/studio/web/list.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Press } from './press.js';
|
|
|
7
7
|
import { hoverOf, modelOf } from './names.js';
|
|
8
8
|
import { remaining, asker, allowSessionLabel, VERDICTS } from './approvals.js';
|
|
9
9
|
import { AskForms } from './ask.js';
|
|
10
|
+
import { t } from './i18n.js';
|
|
10
11
|
|
|
11
12
|
function mk(tag, cls, text) {
|
|
12
13
|
const n = document.createElement(tag);
|
|
@@ -81,8 +82,8 @@ export class List {
|
|
|
81
82
|
this.lastSections = list;
|
|
82
83
|
if (!list.length) {
|
|
83
84
|
const empty = mk('div', 'ls-empty');
|
|
84
|
-
empty.append(mk('strong', null, 'No agents yet'),
|
|
85
|
-
mk('span', null, 'This session has not delegated to a subagent. When it does, each one is listed here.'));
|
|
85
|
+
empty.append(mk('strong', null, t('No agents yet')),
|
|
86
|
+
mk('span', null, t('This session has not delegated to a subagent. When it does, each one is listed here.')));
|
|
86
87
|
this.root.replaceChildren(empty);
|
|
87
88
|
return;
|
|
88
89
|
}
|
|
@@ -104,7 +105,7 @@ export class List {
|
|
|
104
105
|
head.type = 'button';
|
|
105
106
|
head.dataset.key = `sec:${s.key}`;
|
|
106
107
|
head.setAttribute('aria-expanded', String(!s.folded));
|
|
107
|
-
head.append(mk('span', 'row-fill'), mk('span', 'sub', s.folded ? 'show' : 'hide'));
|
|
108
|
+
head.append(mk('span', 'row-fill'), mk('span', 'sub', s.folded ? t('show') : t('hide')));
|
|
108
109
|
head.addEventListener('click', () => { this.folded.set(s.key, !s.folded); this.render(); this.hooks.onFold?.(); });
|
|
109
110
|
}
|
|
110
111
|
sec.append(head);
|
|
@@ -122,13 +123,13 @@ export class List {
|
|
|
122
123
|
const r = remaining(item, now);
|
|
123
124
|
const clock = mk('span', 'ls-clock', r.known ? `${r.left}s` : '?');
|
|
124
125
|
clock.setAttribute('role', 'timer');
|
|
125
|
-
clock.setAttribute('aria-label', r.known ?
|
|
126
|
+
clock.setAttribute('aria-label', r.known ? t('Auto-deny in {n} seconds', { n: r.left }) : t('Time left not measured'));
|
|
126
127
|
head.append(who, mk('span', 'row-fill'), clock);
|
|
127
128
|
card.append(head);
|
|
128
129
|
if (item.detail && !item.ask) card.append(mk('pre', 'ls-cmd', item.detail));
|
|
129
130
|
// A request from another session says which, and goes there.
|
|
130
131
|
if (item.sessionId !== this.current) {
|
|
131
|
-
const where = mk('button', 'ls-where',
|
|
132
|
+
const where = mk('button', 'ls-where', t('in {name}', { name: item.sessionName }));
|
|
132
133
|
where.type = 'button';
|
|
133
134
|
where.addEventListener('click', () => this.hooks.onLocate?.(item));
|
|
134
135
|
card.append(where);
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Offline is not a longer silence. It is the last request failing: a refused connection answers at once, so there
|
|
18
18
|
// is nothing to wait for.
|
|
19
|
+
import { t } from './i18n.js';
|
|
20
|
+
|
|
19
21
|
export const STALE_AFTER_MS = 10000;
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -28,16 +30,16 @@ export function liveness(nowMs, lastOkMs, lastFailed) {
|
|
|
28
30
|
if (lastFailed) {
|
|
29
31
|
return {
|
|
30
32
|
state: 'offline',
|
|
31
|
-
word: 'Offline',
|
|
33
|
+
word: t('Offline'),
|
|
32
34
|
tone: 'fail',
|
|
33
|
-
detail: lastOkMs === null ? 'the server has not answered' :
|
|
35
|
+
detail: lastOkMs === null ? t('the server has not answered') : t('last update {time}', { time: clock(lastOkMs) }),
|
|
34
36
|
};
|
|
35
37
|
}
|
|
36
|
-
if (lastOkMs === null) return { state: 'connecting', word: 'Connecting', tone: null, detail: '' };
|
|
38
|
+
if (lastOkMs === null) return { state: 'connecting', word: t('Connecting'), tone: null, detail: '' };
|
|
37
39
|
const silence = Math.max(0, nowMs - lastOkMs);
|
|
38
|
-
const said =
|
|
39
|
-
if (silence > STALE_AFTER_MS) return { state: 'stale', word: 'Stale', tone: 'waiting', detail: said };
|
|
40
|
-
return { state: 'live', word: 'Live', tone: 'good', detail: said };
|
|
40
|
+
const said = t('{n}s ago', { n: Math.round(silence / 1000) });
|
|
41
|
+
if (silence > STALE_AFTER_MS) return { state: 'stale', word: t('Stale'), tone: 'waiting', detail: said };
|
|
42
|
+
return { state: 'live', word: t('Live'), tone: 'good', detail: said };
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/**
|
|
@@ -53,8 +55,8 @@ export function offlineNote(state, lastOkMs, nextTryMs, nowMs) {
|
|
|
53
55
|
if (state !== 'offline') return null;
|
|
54
56
|
const left = nextTryMs == null ? null : Math.max(0, Math.ceil((nextTryMs - nowMs) / 1000));
|
|
55
57
|
return {
|
|
56
|
-
text: lastOkMs === null ? 'The server has not answered yet, so there is nothing to show.' :
|
|
57
|
-
retry: left == null ? 'Reconnecting' : left === 0 ? 'Reconnecting now' :
|
|
58
|
+
text: lastOkMs === null ? t('The server has not answered yet, so there is nothing to show.') : t('Showing the last update from {time}.', { time: clock(lastOkMs) }),
|
|
59
|
+
retry: left == null ? t('Reconnecting') : left === 0 ? t('Reconnecting now') : t('Reconnecting in {n}s', { n: left }),
|
|
58
60
|
stale: lastOkMs !== null,
|
|
59
61
|
};
|
|
60
62
|
}
|
|
@@ -72,7 +74,7 @@ function clock(ms) {
|
|
|
72
74
|
* changed since it started; a server with no `code` at all predates the field, and is older than this page.
|
|
73
75
|
* Returns the sentence to show, or null.
|
|
74
76
|
*/
|
|
75
|
-
export const RESTART_NOTE = 'Studio was updated after this server started, so it is running the old code. Restart it: stop it where it runs (Ctrl-C), then /crew-studio.';
|
|
77
|
+
export const RESTART_NOTE = t('Studio was updated after this server started, so it is running the old code. Restart it: stop it where it runs (Ctrl-C), then /crew-studio.');
|
|
76
78
|
export function staleServerNote(health) {
|
|
77
79
|
if (!health || typeof health !== 'object') return null;
|
|
78
80
|
if (!health.code || typeof health.code !== 'object') return RESTART_NOTE;
|
|
@@ -7,20 +7,22 @@
|
|
|
7
7
|
|
|
8
8
|
// The layers /crew-map may write (eval/lib/crew-map/narrative.mjs LAYERS), set between the bands the scan alone
|
|
9
9
|
// gives, so a map with no layer is drawn in the same order as before.
|
|
10
|
+
import { t } from './i18n.js';
|
|
11
|
+
|
|
10
12
|
export const LAYER_KEYS = ['ui', 'api', 'service', 'data', 'infra', 'tool', 'library'];
|
|
11
13
|
export const BANDS = [
|
|
12
|
-
['ui', 'UI'],
|
|
13
|
-
['clients', 'Clients'],
|
|
14
|
-
['api', 'API'],
|
|
15
|
-
['entry', 'Entry points'],
|
|
16
|
-
['service', 'Service'],
|
|
17
|
-
['core', 'Core'],
|
|
18
|
-
['data', 'Data'],
|
|
19
|
-
['infra', 'Infrastructure'],
|
|
20
|
-
['library', 'Library'],
|
|
21
|
-
['tool', 'Tools'],
|
|
22
|
-
['external', 'External'],
|
|
23
|
-
['unplaced', 'Unplaced'],
|
|
14
|
+
['ui', t('UI')],
|
|
15
|
+
['clients', t('Clients')],
|
|
16
|
+
['api', t('API')],
|
|
17
|
+
['entry', t('Entry points')],
|
|
18
|
+
['service', t('Service')],
|
|
19
|
+
['core', t('Core')],
|
|
20
|
+
['data', t('Data')],
|
|
21
|
+
['infra', t('Infrastructure')],
|
|
22
|
+
['library', t('Library')],
|
|
23
|
+
['tool', t('Tools')],
|
|
24
|
+
['external', t('External')],
|
|
25
|
+
['unplaced', t('Unplaced')],
|
|
24
26
|
];
|
|
25
27
|
|
|
26
28
|
const RESOURCE = { db: 'db', cache: 'cache', queue: 'queue', auth: 'auth', 'http-api': 'api' };
|
|
@@ -181,7 +183,7 @@ export function findingsOf(doc, { showHidden = false, compare = null } = {}) {
|
|
|
181
183
|
for (const c of compare?.clients ?? []) {
|
|
182
184
|
for (const m of c.missing) {
|
|
183
185
|
all.push({
|
|
184
|
-
id: `cmp:${c.id}:${m.method} ${m.path}`, rule: 'compare-missing', title:
|
|
186
|
+
id: `cmp:${c.id}:${m.method} ${m.path}`, rule: 'compare-missing', title: t('{endpoint} is called by {client} and not provided here', { endpoint: `${m.method} ${m.path}`, client: c.name }),
|
|
185
187
|
proven: true, hidden: false, hiddenReason: null, where: m.evidence?.[0] ? `${m.evidence[0].file}:${m.evidence[0].line}` : '', modules: [`cmp:${c.id}`],
|
|
186
188
|
evidence: m.evidence ?? [], from: compare.label,
|
|
187
189
|
});
|
|
@@ -204,13 +206,15 @@ export function findingCounts(findings) {
|
|
|
204
206
|
return out;
|
|
205
207
|
}
|
|
206
208
|
|
|
209
|
+
const endpoints = (n) => (n === 1 ? t('1 endpoint') : t('{n} endpoints', { n }));
|
|
210
|
+
|
|
207
211
|
/** What a card says under its name. */
|
|
208
212
|
export function cardSub(node, doc) {
|
|
209
|
-
if (node.kind === 'client') return `${node.from} · ${node.calls.length
|
|
210
|
-
if (node.kind === 'service') return node.image ?
|
|
213
|
+
if (node.kind === 'client') return `${node.from} · ${node.calls.length === 1 ? t('1 call here') : t('{n} calls here', { n: node.calls.length })}`;
|
|
214
|
+
if (node.kind === 'service') return node.image ? `${t('container')} · ${node.image}` : t('container');
|
|
211
215
|
const provided = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).length;
|
|
212
216
|
const tech = (node.tech ?? []).slice(0, 2).map((t) => t.replace(/@.*$/, '')).join(' · ');
|
|
213
|
-
return [tech || node.stack, provided ?
|
|
217
|
+
return [tech || node.stack, provided ? endpoints(provided) : null].filter(Boolean).join(' · ');
|
|
214
218
|
}
|
|
215
219
|
|
|
216
220
|
/**
|
|
@@ -220,7 +224,7 @@ export function cardSub(node, doc) {
|
|
|
220
224
|
export const CARD_CHIPS = 3;
|
|
221
225
|
export function cardOf(node, doc) {
|
|
222
226
|
const provided = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).length;
|
|
223
|
-
const kind = [node.narrative?.domain ?? node.stack ?? null, provided ?
|
|
227
|
+
const kind = [node.narrative?.domain ?? node.stack ?? null, provided ? endpoints(provided) : null].filter(Boolean).join(' · ');
|
|
224
228
|
const tech = (node.tech ?? []).map((t) => t.replace(/@.*$/, ''));
|
|
225
229
|
return { kind, chips: tech.slice(0, CARD_CHIPS), more: Math.max(0, tech.length - CARD_CHIPS), outside: outsideOf(node, doc) };
|
|
226
230
|
}
|
|
@@ -264,10 +268,10 @@ export function summaryOf(doc, findings) {
|
|
|
264
268
|
const n = (k) => (doc.nodes ?? []).filter((x) => x.kind === k).length;
|
|
265
269
|
const eps = (doc.provides?.endpoints?.length ?? 0) + (doc.consumes?.endpoints?.length ?? 0);
|
|
266
270
|
return [
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
+
n('module') === 1 ? t('1 module') : t('{n} modules', { n: n('module') }),
|
|
272
|
+
t('{n} external', { n: n('external') }),
|
|
273
|
+
endpoints(eps),
|
|
274
|
+
findings.shown.length === 1 ? t('1 finding') : t('{n} findings', { n: findings.shown.length }),
|
|
271
275
|
];
|
|
272
276
|
}
|
|
273
277
|
|
|
@@ -277,10 +281,10 @@ export function sourceOf(data) {
|
|
|
277
281
|
const behind = data.behind?.count;
|
|
278
282
|
return {
|
|
279
283
|
file: data.file,
|
|
280
|
-
at: commit ?
|
|
281
|
-
behind: typeof behind === 'number' && behind > 0 ?
|
|
284
|
+
at: commit ? t('scanned at {commit}', { commit: commit.slice(0, 7) }) : t('scanned outside git'),
|
|
285
|
+
behind: typeof behind === 'number' && behind > 0 ? (behind === 1 ? t('1 commit behind') : t('{n} commits behind', { n: behind })) : null,
|
|
282
286
|
behindNote: typeof behind === 'number' ? null : (data.behind?.reason ?? null),
|
|
283
|
-
rule: 'Nothing is run; files are only read.',
|
|
287
|
+
rule: t('Nothing is run; files are only read.'),
|
|
284
288
|
};
|
|
285
289
|
}
|
|
286
290
|
|
|
@@ -304,7 +308,7 @@ export function inspectOf(node, doc, edges, findings) {
|
|
|
304
308
|
// /crew-map's description: { text, cites }, each cite a file:line of this node's evidence (eval/lib/crew-map/narrative.mjs).
|
|
305
309
|
narrative: typeof node.narrative?.text === 'string' ? node.narrative.text : null,
|
|
306
310
|
narrativeCites: Array.isArray(node.narrative?.cites) ? node.narrative.cites.filter((c) => typeof c === 'string') : [],
|
|
307
|
-
narrativeNote: 'Not written yet: this map holds no description of the module, only what its files show.',
|
|
311
|
+
narrativeNote: t('Not written yet: this map holds no description of the module, only what its files show.'),
|
|
308
312
|
// /crew-map's layer and domain, when it wrote them; the band is the layer's (bandOf).
|
|
309
313
|
layer: LAYER_KEYS.includes(node.narrative?.layer) ? (BANDS.find(([k]) => k === node.narrative.layer)?.[1] ?? null) : null,
|
|
310
314
|
domain: typeof node.narrative?.domain === 'string' ? node.narrative.domain : null,
|
|
@@ -320,7 +324,7 @@ export function inspectOf(node, doc, edges, findings) {
|
|
|
320
324
|
testFiles: Array.isArray(node.tests?.files) ? node.tests.files.filter((f) => typeof f === 'string') : [],
|
|
321
325
|
testTotal: Number.isInteger(node.tests?.total) ? node.tests.total : 0,
|
|
322
326
|
changed: Number.isInteger(node.commits) && Number.isInteger(doc?.source?.commitsRead) && doc.source.commitsRead > 0
|
|
323
|
-
?
|
|
327
|
+
? (doc.source.commitsRead === 1 ? t('Changed in {n} of the last 1 commit', { n: node.commits }) : t('Changed in {n} of the last {of} commits', { n: node.commits, of: doc.source.commitsRead })) : null,
|
|
324
328
|
notes: (node.markers?.evidence ?? []).filter((e) => e && typeof e.file === 'string').map((e) => ({ at: `${e.file}:${e.line}`, text: String(e.text ?? '') })),
|
|
325
329
|
notesTotal: node.markers?.evidenceTotal ?? (node.markers?.evidence ?? []).length,
|
|
326
330
|
connectsTo: out,
|
|
@@ -345,8 +349,8 @@ export function taskOf(f) {
|
|
|
345
349
|
|
|
346
350
|
/** What the view says when there is no map to draw. */
|
|
347
351
|
export function emptyNote(data) {
|
|
348
|
-
if (!data) return 'Reading the map…';
|
|
349
|
-
if (data.measured === false) return data.missing ?
|
|
352
|
+
if (!data) return t('Reading the map…');
|
|
353
|
+
if (data.measured === false) return data.missing ? t('No map yet — {reason}', { reason: data.reason }) : t('Not read — {reason}', { reason: data.reason });
|
|
350
354
|
return null;
|
|
351
355
|
}
|
|
352
356
|
|
|
@@ -452,5 +456,5 @@ export function chipsOf(node, doc) {
|
|
|
452
456
|
const p = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).map((e) => `${e.method} ${e.path}`);
|
|
453
457
|
const c = (doc.consumes?.endpoints ?? []).filter((e) => e.module === node.id).map((e) => `→ ${e.method} ${e.path}`);
|
|
454
458
|
const all = [...p, ...c];
|
|
455
|
-
return all.length > CHIPS_MAX ? [...all.slice(0, CHIPS_MAX),
|
|
459
|
+
return all.length > CHIPS_MAX ? [...all.slice(0, CHIPS_MAX), t('+{n} more', { n: all.length - CHIPS_MAX })] : all;
|
|
456
460
|
}
|