spectoflow 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -3
- package/lib/adapters.js +15 -0
- package/lib/brain.js +16 -247
- package/lib/dashboard/handlers.js +1 -0
- package/lib/dashboard/isolation.js +177 -0
- package/lib/dashboard/ops.js +56 -1
- package/lib/dashboard/public/app.js +229 -59
- package/lib/dashboard/public/i18n.js +12 -12
- package/lib/dashboard/public/index.html +30 -12
- package/lib/dashboard/public/styles.css +38 -0
- package/lib/dashboard/routes.js +16 -0
- package/lib/dashboard/runner.js +25 -19
- package/lib/mcp-server.js +2 -1
- package/lib/memory-store.js +268 -0
- package/lib/project-memory.js +34 -0
- package/lib/worktree.js +193 -0
- package/package.json +1 -1
- package/templates/SPECTOFLOW.md +31 -0
- package/templates/config.json +1 -0
package/lib/dashboard/ops.js
CHANGED
|
@@ -18,10 +18,13 @@ const orchestrator = require('./orchestrator');
|
|
|
18
18
|
const adapters = require('../adapters');
|
|
19
19
|
const detect = require('../detect');
|
|
20
20
|
const brain = require('../brain');
|
|
21
|
+
const projectMemory = require('../project-memory');
|
|
21
22
|
const brainSetup = require('../brain-setup');
|
|
22
23
|
const globalConfig = require('../global-config');
|
|
23
24
|
const workflowDetect = require('../workflow-detect');
|
|
24
25
|
const runnerTrust = require('../runner-trust');
|
|
26
|
+
const isolation = require('./isolation');
|
|
27
|
+
const worktree = require('../worktree');
|
|
25
28
|
|
|
26
29
|
const PKG_VERSION = require('../../package.json').version;
|
|
27
30
|
|
|
@@ -74,6 +77,7 @@ function writeConfig(root, patch, detectOpts) {
|
|
|
74
77
|
if (typeof patch.activeTab === 'string' && patch.activeTab.trim()) cfg.activeTab = patch.activeTab.trim();
|
|
75
78
|
if (typeof patch.chatOpen === 'boolean') cfg.chatOpen = patch.chatOpen;
|
|
76
79
|
if (typeof patch.workflowAutoEnable === 'boolean') cfg.workflowAutoEnable = patch.workflowAutoEnable;
|
|
80
|
+
if (typeof patch.memoryAutoAdd === 'boolean') cfg.memoryAutoAdd = patch.memoryAutoAdd;
|
|
77
81
|
// kanbanColumns: reject the whole patch (leave the current value untouched) rather than silently
|
|
78
82
|
// filtering out bad entries — an invalid/unknown status id here means the client sent something it
|
|
79
83
|
// shouldn't have, and a real product-safety rule (never persist zero visible columns) applies too.
|
|
@@ -141,6 +145,17 @@ function writeConfig(root, patch, detectOpts) {
|
|
|
141
145
|
fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
|
|
142
146
|
return cfg;
|
|
143
147
|
}
|
|
148
|
+
const localOnly = (ctx) => { if (ctx && ctx.remote) throw new OpError(404, 'Unknown operation.'); };
|
|
149
|
+
const gitOp = (fn) => { try { return fn(); } catch (e) { if (e instanceof worktree.GitError) throw new OpError(e.status, e.message); throw e; } };
|
|
150
|
+
// project.read runs on every change: ask git whether this is a repository at most every 30s.
|
|
151
|
+
const gitRepoSeen = new Map();
|
|
152
|
+
function gitRepoCached(root) {
|
|
153
|
+
const hit = gitRepoSeen.get(root);
|
|
154
|
+
if (hit && Date.now() - hit.at < 30000) return hit.value;
|
|
155
|
+
const value = worktree.isRepo(root);
|
|
156
|
+
gitRepoSeen.set(root, { at: Date.now(), value });
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
144
159
|
const filesResult = (r) => { if (r.error) bad(r.error); return r; };
|
|
145
160
|
const changed = (ctx, result) => { ctx.emit({ type: 'change' }); return result; };
|
|
146
161
|
|
|
@@ -150,8 +165,12 @@ const changed = (ctx, result) => { ctx.emit({ type: 'change' }); return result;
|
|
|
150
165
|
// watches ~/.spectoflow/brain.md and tells local tabs only, whoever wrote it (page, MCP, run line).
|
|
151
166
|
function brainOp(ctx, fn) {
|
|
152
167
|
if (ctx && ctx.remote) throw new OpError(404, 'Unknown operation.');
|
|
153
|
-
|
|
168
|
+
return storeOp(fn);
|
|
154
169
|
}
|
|
170
|
+
const storeOp = (fn) => { try { return fn(); } catch (e) { if (e.status && !(e instanceof OpError)) throw new OpError(e.status, e.message); throw e; } };
|
|
171
|
+
// The project memory is the project's (.spectoflow/memory.md, committed): gated like any project read/write,
|
|
172
|
+
// online included. Only `memory.move` touches the personal brain, so only it is local.
|
|
173
|
+
const memOp = (root, ctx, fn) => { const r = storeOp(() => fn(projectMemory.fileFor(root))); ctx.emit({ type: 'change' }); return r; };
|
|
155
174
|
|
|
156
175
|
const ops = {
|
|
157
176
|
'project.read': async (root) => {
|
|
@@ -166,6 +185,7 @@ const ops = {
|
|
|
166
185
|
p.todayDate = todayLocal();
|
|
167
186
|
p.untrustedRunners = runnerTrust.untrusted(root, p.config);
|
|
168
187
|
p.kitVersion = PKG_VERSION;
|
|
188
|
+
p.git = gitRepoCached(root);
|
|
169
189
|
return p;
|
|
170
190
|
},
|
|
171
191
|
'agentfile.read': async (root, { path: rel }) => readAgentFile(root, rel),
|
|
@@ -243,6 +263,21 @@ const ops = {
|
|
|
243
263
|
return changed(ctx, { fromVersion: r.fromVersion, toVersion: r.toVersion, refreshed: r.refreshed.length + r.created.length + r.forced.length + r.removed.length, review: r.newSidecar });
|
|
244
264
|
},
|
|
245
265
|
'run.stop': async (root, _args, ctx) => changed(ctx, { stopped: stopRuns(root) }),
|
|
266
|
+
|
|
267
|
+
// A task worked on in isolation, in its own git worktree (D79). Merge and pull request change the owner's
|
|
268
|
+
// branches or push: local only, absent from the relay.
|
|
269
|
+
'worktree.start': async (root, { id }, ctx) => gitOp(() => isolation.start(root, { id }, ctx.emit, { remote: !!ctx.remote })),
|
|
270
|
+
'worktree.feedback': async (root, { id, text: body }, ctx) => gitOp(() => {
|
|
271
|
+
const msg = text(body, 'Empty feedback.');
|
|
272
|
+
const file = findPlanFileForTask(root, id); if (!file) notFound(`Task ${id} not found.`);
|
|
273
|
+
store.addTaskComment(root, file, id, msg, 'me');
|
|
274
|
+
return isolation.start(root, { id, feedback: msg }, ctx.emit, { remote: !!ctx.remote });
|
|
275
|
+
}),
|
|
276
|
+
'worktree.diff': async (root, { id }, ctx) => gitOp(() => isolation.diff(root, { id }, { remote: !!ctx.remote })),
|
|
277
|
+
'worktree.stop': async (root, { id }, ctx) => changed(ctx, isolation.stop(root, { id })),
|
|
278
|
+
'worktree.discard': async (root, { id }, ctx) => changed(ctx, gitOp(() => isolation.discard(root, { id }))),
|
|
279
|
+
'worktree.merge': async (root, { id }, ctx) => { localOnly(ctx); return changed(ctx, gitOp(() => isolation.merge(root, { id }))); },
|
|
280
|
+
'worktree.pr': async (root, { id }, ctx) => { localOnly(ctx); return changed(ctx, gitOp(() => isolation.openPr(root, { id }))); },
|
|
246
281
|
'chat.summarize': async (root, { agent }, ctx) => {
|
|
247
282
|
const r = runSummarize(root, { agent }, ctx.emit);
|
|
248
283
|
if (r.error) bad(r.error);
|
|
@@ -307,6 +342,26 @@ const ops = {
|
|
|
307
342
|
return { autoAdd: globalConfig.set('brain.autoAdd', autoAdd) };
|
|
308
343
|
}),
|
|
309
344
|
|
|
345
|
+
'memory.read': async (root) => storeOp(() => ({ ...projectMemory.read(projectMemory.fileFor(root)), path: projectMemory.REL })),
|
|
346
|
+
'memory.add': async (root, { category, text: body }, ctx) => memOp(root, ctx, (f) => projectMemory.add({ category, text: body, by: 'user' }, f)),
|
|
347
|
+
'memory.update': async (root, { id, patch }, ctx) => memOp(root, ctx, (f) => ({ entry: projectMemory.update(id, patch || {}, f) })),
|
|
348
|
+
'memory.remove': async (root, { id }, ctx) => memOp(root, ctx, (f) => projectMemory.remove(id, f)),
|
|
349
|
+
'memory.confirm': async (root, { id }, ctx) => memOp(root, ctx, (f) => ({ entry: projectMemory.confirm(id, f) })),
|
|
350
|
+
// The agent picked the wrong memory: move one entry to the other, under the category the user chose. Added
|
|
351
|
+
// to the target first, removed from the source after — a failure never loses the fact. Local only: one of
|
|
352
|
+
// the two files is always the user's personal brain.
|
|
353
|
+
'memory.move': async (root, { from, id, category }, ctx) => brainOp(ctx, () => {
|
|
354
|
+
if (from !== 'user' && from !== 'project') bad('from must be "user" or "project".');
|
|
355
|
+
const pf = projectMemory.fileFor(root);
|
|
356
|
+
const [src, srcFile, dst, dstFile] = from === 'user' ? [brain, undefined, projectMemory, pf] : [projectMemory, pf, brain, undefined];
|
|
357
|
+
const entry = src.get(id, srcFile);
|
|
358
|
+
if (!dst.CATEGORIES.includes(category)) bad(`Unknown category: ${category}.`);
|
|
359
|
+
const added = dst.add({ category, text: entry.text, by: entry.by }, dstFile);
|
|
360
|
+
src.remove(id, srcFile);
|
|
361
|
+
ctx.emit({ type: 'change' });
|
|
362
|
+
return added;
|
|
363
|
+
}),
|
|
364
|
+
|
|
310
365
|
'attention.remove': async (root, { id }, ctx) => {
|
|
311
366
|
const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id); store.writeRuntime(root, rt);
|
|
312
367
|
return changed(ctx, { ok: true });
|
|
@@ -73,7 +73,7 @@ window.fetch=(url,opts)=>{
|
|
|
73
73
|
return _fetch(url,opts);
|
|
74
74
|
};
|
|
75
75
|
// Served by the online relay? Its project.read always carries `online`; the local hub's never does.
|
|
76
|
-
// Online, the Second brain tab
|
|
76
|
+
// Online, the Second brain tab shows only the project memory: the personal brain is the machine owner's.
|
|
77
77
|
let REMOTE=false;
|
|
78
78
|
function setOffline(p){
|
|
79
79
|
REMOTE=typeof p.online==='boolean';
|
|
@@ -161,14 +161,14 @@ function applyNavTabs() {
|
|
|
161
161
|
list.forEach((entry) => {
|
|
162
162
|
const btn = nav.querySelector('.tab[data-tab="' + entry.id + '"]');
|
|
163
163
|
if (!btn) return;
|
|
164
|
-
btn.hidden = !entry.enabled
|
|
164
|
+
btn.hidden = !entry.enabled;
|
|
165
165
|
nav.insertBefore(btn, anchor);
|
|
166
166
|
});
|
|
167
167
|
// If the currently active NATIVE tab just became disabled (e.g. the user disabled the tab they're
|
|
168
168
|
// viewing, or another viewer did and this tab just reloaded), navigate to a sensible fallback
|
|
169
169
|
// instead of leaving a hidden/disabled panel showing.
|
|
170
170
|
const activeEntry = list.find((e) => e.id === activeTab);
|
|
171
|
-
if (activeEntry &&
|
|
171
|
+
if (activeEntry && !activeEntry.enabled) {
|
|
172
172
|
const fallback = list.find((e) => e.id === 'board' && e.enabled) || list.find((e) => e.enabled);
|
|
173
173
|
if (fallback) navigateTab(fallback.id);
|
|
174
174
|
}
|
|
@@ -299,9 +299,10 @@ const runtimeTests=(id)=> (P.runtime&&P.runtime.tests&&P.runtime.tests[id])||nul
|
|
|
299
299
|
async function load(){
|
|
300
300
|
const r = await fetch(withProject('/api/project')); P = await r.json();
|
|
301
301
|
syncSettingsFromServer();
|
|
302
|
-
REMOTE=typeof P.online==='boolean'; // known before the first paint: the brain
|
|
302
|
+
REMOTE=typeof P.online==='boolean'; // known before the first paint: the personal brain is hidden online
|
|
303
303
|
render(); setOffline(P); renderUpdateBar(); notifyOrchestration();
|
|
304
|
-
if(!REMOTE && !
|
|
304
|
+
if(!REMOTE && !MEMORIES.user.data) loadBrain();
|
|
305
|
+
loadMemory('project'); // .spectoflow/memory.md may be what just changed
|
|
305
306
|
if(openTaskId) openDrawer(openTaskId,true);
|
|
306
307
|
}
|
|
307
308
|
// Coalesce bursts of SSE 'change'/'message' events into one reload so the board doesn't
|
|
@@ -315,6 +316,8 @@ function connect(){
|
|
|
315
316
|
let m; try{ m=JSON.parse(ev.data); }catch{ return; }
|
|
316
317
|
if(m.type==='change'||m.type==='message') return scheduleLoad(); // messages live from runtime.messages
|
|
317
318
|
if(m.type==='brain') return loadBrain(); // ~/.spectoflow/brain.md changed (page, MCP, run line)
|
|
319
|
+
if((m.type==='run-start'||m.type==='run-end') && m.task) return scheduleLoad(); // isolated work: the chat stays free
|
|
320
|
+
if(m.type==='run-line' && m.task) return;
|
|
318
321
|
if(m.type==='run-start'||m.type==='run-end') { chatState.forEach(st=>{ st.rawBlock=null; }); sseBusy=(m.type==='run-start'); updateChatBusyUI(); if(m.type==='run-end') notify(t(m.code===0?'notify.done':'notify.failed',{project:P&&P.projectName||'spectoflow'}), ''); return; }
|
|
319
322
|
if(m.type==='run-line') return appendRaw(m.chunk); // raw output is ephemeral (not logged)
|
|
320
323
|
};
|
|
@@ -981,6 +984,8 @@ function renderTask(t){
|
|
|
981
984
|
c.append(tags);
|
|
982
985
|
}
|
|
983
986
|
const foot=el('div','task-foot');
|
|
987
|
+
const iso=isoState(t.id);
|
|
988
|
+
if(iso) foot.append(el('span','iso-chip is-'+iso.status,'⎇ '+window.t('iso.chip.'+iso.status))); // `t` is the task here
|
|
984
989
|
if(t.owner) foot.append(el('span','owner','@'+t.owner));
|
|
985
990
|
if(t.comments&&t.comments.length) foot.append(el('span','cmt-count','💬 '+t.comments.length));
|
|
986
991
|
const tr=runtimeTests(t.id);
|
|
@@ -1581,7 +1586,7 @@ function navigateTab(tabId,push){
|
|
|
1581
1586
|
// was scrolled to last (only on an actual switch INTO the tab — applyActiveTab() alone runs on
|
|
1582
1587
|
// every SSE render tick too, and re-scrolling/re-focusing there would fight the user's typing).
|
|
1583
1588
|
if(tabId==='chat') setTimeout(()=>{ scrollChat($('#chatTabLog')); $('#tabRunPrompt').focus(); },60);
|
|
1584
|
-
if(tabId==='brain'){ renderBrain(); if(!
|
|
1589
|
+
if(tabId==='brain'){ renderBrain(); if(!MEMORIES.user.data) loadBrain(); if(!MEMORIES.project.data) loadMemory('project'); }
|
|
1585
1590
|
if(tabId==='files') renderFiles(); // the tree is fetched lazily — only load it on an actual switch in
|
|
1586
1591
|
}
|
|
1587
1592
|
function closeNav(){ document.body.classList.remove('nav-open'); const nt=$('#navToggle'); if(nt) nt.setAttribute('aria-expanded','false'); }
|
|
@@ -2159,60 +2164,79 @@ function notesSetStatus(state){
|
|
|
2159
2164
|
}
|
|
2160
2165
|
|
|
2161
2166
|
// ---- Second brain ------------------------------------------------------------------------------
|
|
2162
|
-
//
|
|
2163
|
-
// NOT this project's, so it is fetched on its own
|
|
2164
|
-
// pushes a 'brain' SSE event whenever
|
|
2165
|
-
// `spectoflow mcp`,
|
|
2166
|
-
|
|
2167
|
+
// Two memories on one page, one renderer. "user": what spectoflow has learned about the user —
|
|
2168
|
+
// ~/.spectoflow/brain.md, shared by all their projects, NOT this project's, so it is fetched on its own
|
|
2169
|
+
// (/api/brain) and hidden online; the hub pushes a 'brain' SSE event whenever that file changes, whoever
|
|
2170
|
+
// wrote it (this page, an agent through `spectoflow mcp`, a `::spectoflow learn` run line). "project":
|
|
2171
|
+
// facts about this project — .spectoflow/memory.md, committed; a write to it (page, agent, git) is an
|
|
2172
|
+
// ordinary 'change' event, and load() refetches it.
|
|
2167
2173
|
const BRAIN_SOFT_LIMIT=60;
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2174
|
+
const MEMORIES={
|
|
2175
|
+
user:{ api:'/api/brain', cats:['profile','preferences','workflow','avoid'], other:'project', empty:'brain.empty', dup:'brain.duplicate',
|
|
2176
|
+
path:(d)=>t('brain.path',{path:d.path||'~/.spectoflow/brain.md'}),
|
|
2177
|
+
setAuto:(v)=>memCall('POST','/api/brain/settings',{autoAdd:v}) },
|
|
2178
|
+
project:{ api:'/api/memory', cats:['conventions','pitfalls','glossary','constraints'], other:'user', empty:'brain.projectEmpty', dup:'brain.projectDuplicate',
|
|
2179
|
+
path:(d)=>t('brain.projectPath',{path:d.path||'.spectoflow/memory.md'}),
|
|
2180
|
+
setAuto:(v)=>{ if(P&&P.config) P.config.memoryAutoAdd=v; return memCall('POST','/api/settings',{memoryAutoAdd:v}); } },
|
|
2181
|
+
};
|
|
2182
|
+
Object.values(MEMORIES).forEach(m=>{ m.data=null; m.error=null; m.actionError=null; });
|
|
2183
|
+
const memSection=(key)=>document.querySelector(`[data-memory="${key}"]`);
|
|
2184
|
+
const memUrl=(key,id,suffix)=>`${MEMORIES[key].api}/${encodeURIComponent(id)}${suffix||''}`;
|
|
2185
|
+
async function loadMemory(key){
|
|
2186
|
+
const m=MEMORIES[key];
|
|
2187
|
+
if(key==='user' && REMOTE) return;
|
|
2171
2188
|
try{
|
|
2172
|
-
const r=await fetch(withProject(
|
|
2189
|
+
const r=await fetch(withProject(m.api));
|
|
2173
2190
|
const d=await r.json().catch(()=>({}));
|
|
2174
2191
|
if(!r.ok) throw new Error(d.error||'error');
|
|
2175
|
-
|
|
2176
|
-
}catch(err){
|
|
2192
|
+
m.data=d; m.error=null;
|
|
2193
|
+
}catch(err){ m.error=err.message||'error'; }
|
|
2177
2194
|
renderBrain();
|
|
2178
2195
|
}
|
|
2179
|
-
|
|
2196
|
+
function loadBrain(){ return loadMemory('user'); }
|
|
2197
|
+
async function memCall(method,url,body){
|
|
2180
2198
|
flash();
|
|
2181
2199
|
const r=await fetch(withProject(url),{method,headers:{'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body)});
|
|
2182
2200
|
const d=await r.json().catch(()=>({}));
|
|
2183
2201
|
if(!r.ok) throw new Error(d.error||t('brain.error'));
|
|
2184
2202
|
return d;
|
|
2185
2203
|
}
|
|
2186
|
-
// A re-render (SSE tick, another tab's write) must never wipe what the user is typing.
|
|
2204
|
+
// A re-render (SSE tick, another tab's write) must never wipe what the user is typing or picking.
|
|
2187
2205
|
function brainIsEditing(){
|
|
2188
2206
|
const a=document.activeElement;
|
|
2189
|
-
return !!(a &&
|
|
2207
|
+
return !!(a && ['INPUT','TEXTAREA','SELECT'].includes(a.tagName) && a.closest('.mem-grid, .mem-pending'));
|
|
2190
2208
|
}
|
|
2191
2209
|
function renderBrain(){
|
|
2192
2210
|
const badge=$('#brainBadge');
|
|
2193
|
-
const pendingN=
|
|
2211
|
+
const pendingN=Object.entries(MEMORIES).reduce((n,[k,m])=> n+((m.data && !(k==='user'&&REMOTE)) ? m.data.pending.length : 0),0);
|
|
2194
2212
|
if(badge){ badge.textContent=pendingN; badge.hidden=pendingN===0; }
|
|
2195
2213
|
if(activeTab!=='brain' || brainIsEditing()) return;
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
const
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
const
|
|
2214
|
+
Object.keys(MEMORIES).forEach(renderMemory);
|
|
2215
|
+
}
|
|
2216
|
+
function renderMemory(key){
|
|
2217
|
+
const m=MEMORIES[key], sec=memSection(key); if(!sec) return;
|
|
2218
|
+
sec.hidden = key==='user' && REMOTE;
|
|
2219
|
+
if(sec.hidden) return;
|
|
2220
|
+
const grid=sec.querySelector('.mem-grid');
|
|
2221
|
+
if(m.error && !m.data){ grid.innerHTML=''; grid.append(el('div','empty',t('brain.error'))); return; }
|
|
2222
|
+
if(!m.data){ grid.innerHTML=''; grid.append(el('div','empty',t('drawer.loading'))); return; }
|
|
2223
|
+
const d=m.data;
|
|
2224
|
+
sec.querySelector('.mem-count').textContent=d.entries.length;
|
|
2225
|
+
sec.querySelector('.mem-auto').checked=!!d.autoAdd;
|
|
2226
|
+
sec.querySelector('.mem-auto-hint').textContent=d.autoAdd ? t('brain.autoAddOn') : t('brain.autoAddOff');
|
|
2227
|
+
const agents=sec.querySelector('.mem-agents'); if(agents) renderBrainAgents(agents, d.agents||[]);
|
|
2228
|
+
const notice=sec.querySelector('.mem-notice');
|
|
2229
|
+
const noticeText=m.actionError || (d.entries.length>BRAIN_SOFT_LIMIT ? t('brain.tooMany',{n:d.entries.length}) : '');
|
|
2206
2230
|
notice.hidden=!noticeText; notice.textContent=noticeText;
|
|
2207
|
-
notice.classList.toggle('is-error',!!
|
|
2208
|
-
|
|
2231
|
+
notice.classList.toggle('is-error',!!m.actionError);
|
|
2232
|
+
renderMemoryPending(key, sec.querySelector('.mem-pending'), d.pending);
|
|
2209
2233
|
grid.innerHTML='';
|
|
2210
|
-
if(!d.entries.length && !d.pending.length) grid.append(el('div','brain-empty',t(
|
|
2211
|
-
|
|
2212
|
-
|
|
2234
|
+
if(!d.entries.length && !d.pending.length) grid.append(el('div','brain-empty',t(m.empty)));
|
|
2235
|
+
m.cats.forEach(cat=> grid.append(memoryCard(key, cat, d.entries.filter(e=>e.category===cat))));
|
|
2236
|
+
sec.querySelector('.mem-path').textContent=m.path(d);
|
|
2213
2237
|
}
|
|
2214
|
-
function renderBrainAgents(agents){
|
|
2215
|
-
|
|
2238
|
+
function renderBrainAgents(box, agents){
|
|
2239
|
+
box.innerHTML='';
|
|
2216
2240
|
if(!agents.length){ box.append(el('span','brain-agents-none',t('brain.noAgents'))); return; }
|
|
2217
2241
|
box.append(el('span','brain-agents-label',t('brain.agents')));
|
|
2218
2242
|
agents.forEach(a=>{
|
|
@@ -2230,13 +2254,26 @@ function brainMeta(e){
|
|
|
2230
2254
|
const who=e.by==='agent' ? t('brain.byAgent') : t('brain.byYou');
|
|
2231
2255
|
return e.at ? `${who} · ${e.at}` : who;
|
|
2232
2256
|
}
|
|
2233
|
-
|
|
2234
|
-
|
|
2257
|
+
// "Move to…": the agent picked the wrong memory. Lists the other memory's categories; local only (one of the
|
|
2258
|
+
// two files is the user's personal brain), so never offered online.
|
|
2259
|
+
function memoryMoveSelect(key, e){
|
|
2260
|
+
if(REMOTE) return null;
|
|
2261
|
+
const target=MEMORIES[key].other;
|
|
2262
|
+
const sel=el('select','brain-move'); sel.title=t('brain.moveHint');
|
|
2263
|
+
const ph=el('option',null,t('brain.moveTo')); ph.value=''; ph.selected=true; ph.disabled=true; sel.append(ph);
|
|
2264
|
+
const group=document.createElement('optgroup'); group.label=t(target==='user'?'brain.you':'brain.project');
|
|
2265
|
+
MEMORIES[target].cats.forEach(c=>{ const o=el('option',null,t('brain.cat.'+c)); o.value=c; group.append(o); });
|
|
2266
|
+
sel.append(group);
|
|
2267
|
+
sel.addEventListener('change',()=>{ const category=sel.value; sel.blur(); memAct(key,()=>memCall('POST','/api/memory/move',{from:key,id:e.id,category}),true); });
|
|
2268
|
+
return sel;
|
|
2269
|
+
}
|
|
2270
|
+
function renderMemoryPending(key, box, pending){
|
|
2271
|
+
box.innerHTML='';
|
|
2235
2272
|
box.hidden=!pending.length; if(!pending.length) return;
|
|
2236
2273
|
const head=el('div','brain-pending-head');
|
|
2237
2274
|
head.append(el('h3','brain-pending-title',t('brain.toConfirm')));
|
|
2238
2275
|
const all=el('button','btn primary',t('brain.confirmAll'));
|
|
2239
|
-
all.addEventListener('click',async()=>{ all.disabled=true; try{ for(const e of pending) await
|
|
2276
|
+
all.addEventListener('click',async()=>{ all.disabled=true; try{ for(const e of pending) await memCall('POST',memUrl(key,e.id,'/confirm'),{}); }catch(err){} loadMemory(key); });
|
|
2240
2277
|
head.append(all); box.append(head);
|
|
2241
2278
|
pending.forEach(e=>{
|
|
2242
2279
|
const row=el('div','brain-row is-pending');
|
|
@@ -2244,14 +2281,17 @@ function renderBrainPending(pending){
|
|
|
2244
2281
|
const txt=el('div','brain-text',e.text); row.append(txt);
|
|
2245
2282
|
row.append(el('div','brain-meta',brainMeta(e)));
|
|
2246
2283
|
const acts=el('div','brain-actions');
|
|
2247
|
-
const ok=el('button','btn primary',t('brain.confirm')); ok.addEventListener('click',()=>
|
|
2248
|
-
const ed=el('button','btn',t('action.edit')); ed.addEventListener('click',()=>
|
|
2249
|
-
const no=el('button','btn danger',t('brain.reject')); no.addEventListener('click',()=>
|
|
2250
|
-
acts.append(ok,ed,no);
|
|
2284
|
+
const ok=el('button','btn primary',t('brain.confirm')); ok.addEventListener('click',()=>memAct(key,()=>memCall('POST',memUrl(key,e.id,'/confirm'),{})));
|
|
2285
|
+
const ed=el('button','btn',t('action.edit')); ed.addEventListener('click',()=>memEdit(key,e,txt));
|
|
2286
|
+
const no=el('button','btn danger',t('brain.reject')); no.addEventListener('click',()=>memAct(key,()=>memCall('DELETE',memUrl(key,e.id))));
|
|
2287
|
+
acts.append(ok,ed,no);
|
|
2288
|
+
const mv=memoryMoveSelect(key,e); if(mv) acts.append(mv);
|
|
2289
|
+
row.append(acts);
|
|
2251
2290
|
box.append(row);
|
|
2252
2291
|
});
|
|
2253
2292
|
}
|
|
2254
|
-
function
|
|
2293
|
+
function memoryCard(key, cat, entries){
|
|
2294
|
+
const m=MEMORIES[key];
|
|
2255
2295
|
const card=el('div','brain-card');
|
|
2256
2296
|
const head=el('div','brain-card-head');
|
|
2257
2297
|
head.append(el('h3','brain-card-title',t('brain.cat.'+cat)), el('span','count',String(entries.length)));
|
|
@@ -2264,9 +2304,11 @@ function brainCard(cat, entries){
|
|
|
2264
2304
|
const foot=el('div','brain-row-foot');
|
|
2265
2305
|
foot.append(el('span','brain-meta',brainMeta(e)));
|
|
2266
2306
|
const acts=el('span','brain-actions');
|
|
2267
|
-
const ed=el('button','btn btn-xs',t('action.edit')); ed.addEventListener('click',()=>
|
|
2268
|
-
const del=el('button','btn btn-xs danger',t('action.delete')); del.addEventListener('click',()=>
|
|
2269
|
-
acts.append(ed,del);
|
|
2307
|
+
const ed=el('button','btn btn-xs',t('action.edit')); ed.addEventListener('click',()=>memEdit(key,e,txt));
|
|
2308
|
+
const del=el('button','btn btn-xs danger',t('action.delete')); del.addEventListener('click',()=>memAct(key,()=>memCall('DELETE',memUrl(key,e.id))));
|
|
2309
|
+
acts.append(ed,del);
|
|
2310
|
+
const mv=memoryMoveSelect(key,e); if(mv) acts.append(mv);
|
|
2311
|
+
foot.append(acts); row.append(foot);
|
|
2270
2312
|
list.append(row);
|
|
2271
2313
|
});
|
|
2272
2314
|
card.append(list);
|
|
@@ -2280,28 +2322,28 @@ function brainCard(cat, entries){
|
|
|
2280
2322
|
const text=input.value.trim(); if(!text){ input.focus(); return; }
|
|
2281
2323
|
btn.disabled=true; err.hidden=true;
|
|
2282
2324
|
try{
|
|
2283
|
-
const r=await
|
|
2325
|
+
const r=await memCall('POST',m.api,{category:cat,text});
|
|
2284
2326
|
input.value=''; input.blur();
|
|
2285
|
-
if(r.duplicate){ err.textContent=t(
|
|
2327
|
+
if(r.duplicate){ err.textContent=t(m.dup); err.hidden=false; }
|
|
2286
2328
|
}catch(e){ err.textContent=e.message; err.hidden=false; }
|
|
2287
|
-
btn.disabled=false;
|
|
2329
|
+
btn.disabled=false; loadMemory(key);
|
|
2288
2330
|
});
|
|
2289
2331
|
card.append(add);
|
|
2290
2332
|
return card;
|
|
2291
2333
|
}
|
|
2292
|
-
async function
|
|
2293
|
-
try{ await fn();
|
|
2294
|
-
|
|
2334
|
+
async function memAct(key, fn, both){
|
|
2335
|
+
try{ await fn(); MEMORIES[key].actionError=null; }catch(err){ MEMORIES[key].actionError=err.message; }
|
|
2336
|
+
loadMemory(key); if(both) loadMemory(MEMORIES[key].other);
|
|
2295
2337
|
}
|
|
2296
2338
|
// Inline edit, same interaction as the Attention tab: blur or Ctrl/Cmd+Enter saves, Escape cancels.
|
|
2297
|
-
function
|
|
2339
|
+
function memEdit(key, e, txtNode){
|
|
2298
2340
|
const ta=el('textarea','brain-edit'); ta.value=e.text; ta.maxLength=500; txtNode.replaceWith(ta); ta.focus();
|
|
2299
2341
|
let done=false;
|
|
2300
2342
|
const finish=async(save)=>{
|
|
2301
2343
|
if(done) return; done=true;
|
|
2302
2344
|
const v=ta.value.trim();
|
|
2303
2345
|
ta.blur();
|
|
2304
|
-
if(save && v && v!==e.text) await
|
|
2346
|
+
if(save && v && v!==e.text) await memAct(key,()=>memCall('PATCH',memUrl(key,e.id),{text:v}));
|
|
2305
2347
|
else renderBrain();
|
|
2306
2348
|
};
|
|
2307
2349
|
ta.addEventListener('blur',()=>finish(true));
|
|
@@ -2459,7 +2501,11 @@ function openDrawer(id,keep){
|
|
|
2459
2501
|
const task=allTasks().find(x=>x.id===id); if(!task) return;
|
|
2460
2502
|
openTaskId=id;
|
|
2461
2503
|
if(!keep && taskFromPath()!==id) history.pushState(null,'',projectPath('/'+activeTab+'/'+encodeURIComponent(id)));
|
|
2462
|
-
const b=$('#drawerBody'); const prev=keep?$('.drawer-panel').scrollTop:0;
|
|
2504
|
+
const b=$('#drawerBody'); const prev=keep?$('.drawer-panel').scrollTop:0;
|
|
2505
|
+
// A re-render (any SSE tick) must not steal the feedback being typed: remember focus and caret.
|
|
2506
|
+
const focused=document.activeElement&&document.activeElement.closest&&document.activeElement.closest('.iso-feedback') ? document.activeElement : null;
|
|
2507
|
+
const caret=focused?[focused.selectionStart,focused.selectionEnd]:null;
|
|
2508
|
+
b.innerHTML='';
|
|
2463
2509
|
b.append(el('div','d-id',task.id+' · '+(task.level||'standard')+' · '+task.file));
|
|
2464
2510
|
b.append(el('div','d-title',task.title));
|
|
2465
2511
|
const sSec=el('div','d-section'); sSec.append(el('div','d-label',t('task.status')));
|
|
@@ -2471,6 +2517,7 @@ function openDrawer(id,keep){
|
|
|
2471
2517
|
sr.append(btn);
|
|
2472
2518
|
});
|
|
2473
2519
|
sSec.append(sr); b.append(sSec);
|
|
2520
|
+
renderIsolation(b,task);
|
|
2474
2521
|
|
|
2475
2522
|
const tr=runtimeTests(id);
|
|
2476
2523
|
if(tr){ const ts=el('div','d-section'); ts.append(el('div','d-label',t('task.tests')));
|
|
@@ -2491,6 +2538,129 @@ function openDrawer(id,keep){
|
|
|
2491
2538
|
cSec.append(box); b.append(cSec);
|
|
2492
2539
|
$('#drawer').setAttribute('aria-hidden','false');
|
|
2493
2540
|
if(keep) $('.drawer-panel').scrollTop=prev;
|
|
2541
|
+
if(caret){ const ta=b.querySelector('.iso-feedback textarea'); if(ta){ ta.focus(); ta.setSelectionRange(caret[0],caret[1]); } }
|
|
2542
|
+
}
|
|
2543
|
+
// ---- Isolated work (D79) -------------------------------------------------------------------------
|
|
2544
|
+
// A task worked on in its own git worktree: the agent's change stays off the user's working tree until
|
|
2545
|
+
// they review the diff and merge it, open a pull request, send feedback, or discard it. State comes from
|
|
2546
|
+
// runtime.worktrees (P.runtime); the diff is fetched on demand and kept until the run changes. Merge and pull
|
|
2547
|
+
// request are local only (the relay refuses them), so they aren't offered online.
|
|
2548
|
+
const isoDiffs={}, isoDraft={}, isoErrors={};
|
|
2549
|
+
let isoOpenPatch=null, isoArmed=null;
|
|
2550
|
+
const isoState=(id)=> (P&&P.runtime&&P.runtime.worktrees||{})[id]||null;
|
|
2551
|
+
const isoKey=(w)=> w ? `${w.runId}|${w.status}|${w.endedAt||''}` : '';
|
|
2552
|
+
async function isoCall(id,action,body,method){
|
|
2553
|
+
flash();
|
|
2554
|
+
const r=await fetch(withProject('/api/task/'+encodeURIComponent(id)+'/isolate'+(action?'/'+action:'')),{method:method||'POST',headers:{'Content-Type':'application/json'},body:method==='GET'?undefined:JSON.stringify(body||{})});
|
|
2555
|
+
const d=await r.json().catch(()=>({}));
|
|
2556
|
+
if(!r.ok) throw new Error(d.error||t('iso.error'));
|
|
2557
|
+
return d;
|
|
2558
|
+
}
|
|
2559
|
+
// The outcome of an action shows after the next render: success arrives as a 'change' event; a refusal
|
|
2560
|
+
// (a merge conflict, gh missing…) is kept per task until the next action.
|
|
2561
|
+
function isoAct(id,btn,fn){
|
|
2562
|
+
btn.disabled=true; delete isoErrors[id];
|
|
2563
|
+
return fn().then(()=>{ delete isoDiffs[id]; },(err)=>{ isoErrors[id]=err.message; delete isoDiffs[id]; if(openTaskId===id) openDrawer(id,true); });
|
|
2564
|
+
}
|
|
2565
|
+
async function loadIsoDiff(id){
|
|
2566
|
+
const w=isoState(id), key=isoKey(w);
|
|
2567
|
+
try{ isoDiffs[id]={key, data:await isoCall(id,'diff',null,'GET')}; }
|
|
2568
|
+
catch(err){ isoDiffs[id]={key, error:err.message}; }
|
|
2569
|
+
if(openTaskId===id) openDrawer(id,true);
|
|
2570
|
+
}
|
|
2571
|
+
function renderIsoDiff(sec,id){
|
|
2572
|
+
const cached=isoDiffs[id];
|
|
2573
|
+
if(!cached){ sec.append(el('div','empty',t('drawer.loading'))); return null; }
|
|
2574
|
+
if(cached.error){ sec.append(el('div','iso-error',cached.error)); return null; }
|
|
2575
|
+
const d=cached.data;
|
|
2576
|
+
if(!d.files.length){ sec.append(el('div','empty',t('iso.noChanges'))); return d; }
|
|
2577
|
+
const files=el('div','iso-files');
|
|
2578
|
+
d.files.forEach(f=>{
|
|
2579
|
+
const row=el('div','iso-file');
|
|
2580
|
+
row.append(el('span','iso-path',f.path));
|
|
2581
|
+
const n=el('span','iso-count');
|
|
2582
|
+
if(f.added===null) n.textContent=t('iso.binary');
|
|
2583
|
+
else n.append(el('span','iso-plus','+'+f.added),' ',el('span','iso-minus','−'+f.removed));
|
|
2584
|
+
row.append(n); files.append(row);
|
|
2585
|
+
});
|
|
2586
|
+
sec.append(files);
|
|
2587
|
+
const open=isoOpenPatch===id;
|
|
2588
|
+
const toggle=el('button','btn btn-xs',open?t('iso.hideDiff'):t('iso.showDiff'));
|
|
2589
|
+
toggle.addEventListener('click',()=>{ isoOpenPatch=open?null:id; openDrawer(id,true); });
|
|
2590
|
+
sec.append(toggle);
|
|
2591
|
+
if(open){
|
|
2592
|
+
const wrap=el('div','iso-patch'), pre=el('pre');
|
|
2593
|
+
d.patch.split('\n').forEach(line=>{
|
|
2594
|
+
const cls=/^(\+\+\+|---)/.test(line)?'iso-l-meta':line[0]==='+'?'iso-l-add':line[0]==='-'?'iso-l-del':/^@@/.test(line)?'iso-l-hunk':/^diff --git/.test(line)?'iso-l-file':'';
|
|
2595
|
+
pre.append(el('span',cls,line+'\n'));
|
|
2596
|
+
});
|
|
2597
|
+
wrap.append(pre); sec.append(wrap);
|
|
2598
|
+
if(d.truncated) sec.append(el('div','empty',t('iso.truncated')));
|
|
2599
|
+
}
|
|
2600
|
+
return d;
|
|
2601
|
+
}
|
|
2602
|
+
function renderIsolation(b,task){
|
|
2603
|
+
if(!P.git) return;
|
|
2604
|
+
const id=task.id, w=isoState(id);
|
|
2605
|
+
const sec=el('div','d-section iso'); sec.append(el('div','d-label',t('iso.title')));
|
|
2606
|
+
b.append(sec);
|
|
2607
|
+
if(!w){
|
|
2608
|
+
sec.append(el('p','iso-hint',t('iso.hint')));
|
|
2609
|
+
if(isoErrors[id]) sec.append(el('div','iso-error',isoErrors[id]));
|
|
2610
|
+
const go=el('button','btn primary',t('iso.start'));
|
|
2611
|
+
go.addEventListener('click',()=>isoAct(id,go,()=>isoCall(id,'')));
|
|
2612
|
+
sec.append(go);
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2615
|
+
const head=el('div','iso-head');
|
|
2616
|
+
head.append(el('span','iso-status is-'+w.status,t('iso.status.'+w.status)), el('code','iso-branch',w.branch));
|
|
2617
|
+
sec.append(head);
|
|
2618
|
+
if(w.status==='running'){
|
|
2619
|
+
const recent=((P.runtime.messages)||[]).filter(m=>m.runId&&m.runId===w.runId&&m.kind!=='status').slice(-4);
|
|
2620
|
+
if(recent.length){ const log=el('div','iso-log'); recent.forEach(m=>log.append(el('div','iso-log-line',m.text))); sec.append(log); }
|
|
2621
|
+
if(isoErrors[id]) sec.append(el('div','iso-error',isoErrors[id]));
|
|
2622
|
+
const stop=el('button','btn danger',t('chat.stop'));
|
|
2623
|
+
stop.addEventListener('click',()=>isoAct(id,stop,()=>isoCall(id,'stop')));
|
|
2624
|
+
sec.append(stop);
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
if(w.uncommitted) sec.append(el('p','iso-hint',t('iso.uncommitted',{n:w.uncommitted})));
|
|
2628
|
+
if(w.error) sec.append(el('div','iso-error',w.error));
|
|
2629
|
+
if(w.output){ const said=el('div','iso-said'); said.append(el('div','iso-said-label',t('iso.said')), el('div','iso-said-text',w.output)); sec.append(said); }
|
|
2630
|
+
if(w.prUrl){ const a=el('a','iso-pr',t('iso.prOpened')); a.href=w.prUrl; a.target='_blank'; a.rel='noopener noreferrer'; sec.append(a); }
|
|
2631
|
+
if(!isoDiffs[id] || isoDiffs[id].key!==isoKey(w)){ if(!isoDiffs[id] || !isoDiffs[id].loading){ isoDiffs[id]={key:isoKey(w),loading:true}; loadIsoDiff(id); } }
|
|
2632
|
+
const d=isoDiffs[id]&&!isoDiffs[id].loading ? renderIsoDiff(sec,id) : (sec.append(el('div','empty',t('drawer.loading'))), null);
|
|
2633
|
+
const acts=el('div','iso-actions');
|
|
2634
|
+
const hasChanges=!!(d&&d.files.length);
|
|
2635
|
+
if(!REMOTE && hasChanges){
|
|
2636
|
+
const merge=el('button','btn primary',t('iso.merge')); merge.title=t('iso.mergeHint');
|
|
2637
|
+
merge.addEventListener('click',()=>isoAct(id,merge,()=>isoCall(id,'merge')));
|
|
2638
|
+
acts.append(merge);
|
|
2639
|
+
if(d.pr&&d.pr.ok&&w.status!=='pr'){
|
|
2640
|
+
const pr=el('button','btn',t('iso.pr'));
|
|
2641
|
+
pr.addEventListener('click',()=>isoAct(id,pr,()=>isoCall(id,'pr').then(r=>{ if(r.url) window.open(r.url,'_blank','noopener'); })));
|
|
2642
|
+
acts.append(pr);
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
// Discard asks for a second click within 4s; the armed state outlives re-renders.
|
|
2646
|
+
const armed=isoArmed&&isoArmed.id===id&&Date.now()<isoArmed.until;
|
|
2647
|
+
const discard=el('button','btn danger',armed?t('iso.discardConfirm'):t('iso.discard')); discard.title=t('iso.discardHint');
|
|
2648
|
+
discard.addEventListener('click',()=>{
|
|
2649
|
+
if(isoArmed&&isoArmed.id===id&&Date.now()<isoArmed.until){ isoArmed=null; isoAct(id,discard,()=>isoCall(id,'discard')); return; }
|
|
2650
|
+
isoArmed={id,until:Date.now()+4000}; discard.textContent=t('iso.discardConfirm');
|
|
2651
|
+
setTimeout(()=>{ if(isoArmed&&isoArmed.id===id&&Date.now()>=isoArmed.until){ isoArmed=null; if(openTaskId===id) openDrawer(id,true); } },4100);
|
|
2652
|
+
});
|
|
2653
|
+
acts.append(discard);
|
|
2654
|
+
sec.append(acts);
|
|
2655
|
+
if(!REMOTE && hasChanges && d.pr && !d.pr.ok && w.status!=='pr') sec.append(el('p','iso-hint',t('iso.prUnavailable',{reason:d.pr.reason})));
|
|
2656
|
+
if(isoErrors[id]) sec.append(el('div','iso-error',isoErrors[id]));
|
|
2657
|
+
const fb=el('div','c-box iso-feedback');
|
|
2658
|
+
const ta=el('textarea'); ta.placeholder=t('iso.feedbackPlaceholder'); ta.value=isoDraft[id]||'';
|
|
2659
|
+
ta.addEventListener('input',()=>{ isoDraft[id]=ta.value; });
|
|
2660
|
+
const send=el('button','btn',t('iso.feedback'));
|
|
2661
|
+
send.addEventListener('click',()=>{ const v=ta.value.trim(); if(!v){ ta.focus(); return; } isoAct(id,send,()=>isoCall(id,'feedback',{text:v}).then(()=>{ delete isoDraft[id]; })); });
|
|
2662
|
+
const row=el('div','c-actions'); row.append(send);
|
|
2663
|
+
fb.append(ta,row); sec.append(fb);
|
|
2494
2664
|
}
|
|
2495
2665
|
function closeDrawer(){ if(taskFromPath()) history.pushState(null,'',projectPath('/'+activeTab)); openTaskId=null; $('#drawer').setAttribute('aria-hidden','true'); }
|
|
2496
2666
|
const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#888';
|
|
@@ -2685,7 +2855,7 @@ $$('.chat-stop').forEach(b=>b.addEventListener('click',async()=>{ b.disabled=tru
|
|
|
2685
2855
|
$('#wfAnalyzeBtn').addEventListener('click',analyzeWorkflow);
|
|
2686
2856
|
$('#updateBtn').addEventListener('click',updateProject);
|
|
2687
2857
|
$('#setWorkflowAuto').addEventListener('change',(e)=>{ if(P&&P.config) P.config.workflowAutoEnable=e.target.checked; saveSetting({workflowAutoEnable:e.target.checked}); });
|
|
2688
|
-
|
|
2858
|
+
$$('[data-memory]').forEach(sec=>{ const key=sec.dataset.memory; sec.querySelector('.mem-auto').addEventListener('change',(e)=>memAct(key,()=>MEMORIES[key].setAuto(e.target.checked))); });
|
|
2689
2859
|
$('#attnAddBtn').addEventListener('click',()=>{ const t=$('#attnInput'); const v=t.value.trim(); if(v){ addAttn(v); t.value=''; } });
|
|
2690
2860
|
$('#attnInput').addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ const v=e.target.value.trim(); if(v){ addAttn(v); e.target.value=''; } } });
|
|
2691
2861
|
$$('.attn-filters .fchip').forEach(b=> b.addEventListener('click',()=>{ attnFilter=b.dataset.attn; renderAttention(); }));
|