v1.2.0: advisor is per session and off by default (new and existing sessions); old global setting ignored
This commit is contained in:
1 parent
f85f445ef6
commit
0f423e3699
6 files changed
+148
-51
No files matched your search
@@ -14,14 +14,22 @@
|
|||||||
|
|
||||||
Blockers are for real damage or clear wrong turns: destructive commands, secret leaks, pushing broken code, ignoring explicit instructions, going in circles. Concerns cover likely bugs, wrong assumptions, unverified claims, or a much better approach.
|
Blockers are for real damage or clear wrong turns: destructive commands, secret leaks, pushing broken code, ignoring explicit instructions, going in circles. Concerns cover likely bugs, wrong assumptions, unverified claims, or a much better approach.
|
||||||
|
|
||||||
## Picking the advisor in the composer
|
## Per session, off by default
|
||||||
|
|
||||||
|
The advisor is chosen **per session**. Every session, new or existing, starts with the advisor **off**. Turning it on, or picking a model, in one session doesn't affect any other.
|
||||||
|
|
||||||
The plugin adds an **Advisor** control to the composer bar, next to the model picker:
|
The plugin adds an **Advisor** control to the composer bar, next to the model picker:
|
||||||
- **Off**: no reviews.
|
- **Off**: no reviews. This is the default.
|
||||||
- **Same model as the agent**: the default.
|
- **Same model as the agent**: turns it on with the session's own model.
|
||||||
- **Any model from DSH's model picker**: the same provider-grouped list. **Hover** a model that has reasoning efforts to open a side menu and pick its effort; clicking the model uses its default effort. The keyboard works too (→ opens the effort menu, ← goes back, Esc closes).
|
- **Any model from DSH's model picker**: the same provider-grouped list. **Hover** a model that has reasoning efforts to open a side menu and pick its effort; clicking the model uses its default effort. The keyboard works too (→ opens the effort menu, ← goes back, Esc closes).
|
||||||
|
|
||||||
The trigger shows the current choice, e.g. `Claude Haiku · Low` or `Advisor off`. The choice is saved on the host (`<profile>/omp-advisor.json`, through `GET`/`POST /omp-advisor/settings`). It applies from the advisor's next review, with no restart, and it overrides `enabled`, `provider`, `model` and `reasoningEffort` from the config below. Picking an advisor never changes the agent's own model.
|
The trigger shows this session's choice, e.g. `Claude Haiku · Low` or `Advisor off`.
|
||||||
|
- **Where it's saved:** on the host in `<profile>/omp-advisor/sessions.json`, keyed by session id, through `GET`/`POST /omp-advisor/settings?session=<id>`. The newest 2,000 sessions are kept.
|
||||||
|
- **When it applies:** from that session's next review, with no restart.
|
||||||
|
- **What it overrides:** `enabled`, `provider`, `model`, `reasoningEffort` and `minLevel` from the config below, for that session only.
|
||||||
|
- **What it never touches:** the agent's own model.
|
||||||
|
|
||||||
|
Upgrading from 1.1: the old global `<profile>/omp-advisor.json` is ignored, so no session stays on without being turned on again.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -29,14 +37,14 @@ The trigger shows the current choice, e.g. `Claude Haiku · Low` or `Advisor off
|
|||||||
dsh plugin --profile web add git+https://git.rizzy.dev/DSH-Plugins/omp-advisor.git
|
dsh plugin --profile web add git+https://git.rizzy.dev/DSH-Plugins/omp-advisor.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Or in the Web UI, go to **Settings → Plugins → Install**. For development, use `link:` to a clone. Restart DSH afterwards; new sessions are advised automatically.
|
Or in the Web UI, go to **Settings → Plugins → Install**. For development, use `link:` to a clone. Restart DSH afterwards, then turn the advisor on in the sessions where you want it.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- id: omp-advisor
|
- id: omp-advisor
|
||||||
config:
|
config:
|
||||||
enabled: true
|
enabled: false # default for sessions that haven't been switched on in the composer
|
||||||
provider: '' # advisor model route; empty = same as the agent
|
provider: '' # advisor model route; empty = same as the agent
|
||||||
model: '' # e.g. provider: omni, model: claude-haiku-4-5 for a cheap fast reviewer
|
model: '' # e.g. provider: omni, model: claude-haiku-4-5 for a cheap fast reviewer
|
||||||
reasoningEffort: '' # optional effort for the advisor model
|
reasoningEffort: '' # optional effort for the advisor model
|
||||||
|
|||||||
@@ -85,14 +85,15 @@ window.__ModuleLoader__.load({
|
|||||||
const fill = (s, v) => String(s).replace(/\{(\w+)\}/g, (_, k) => (v && v[k] !== undefined ? v[k] : ''));
|
const fill = (s, v) => String(s).replace(/\{(\w+)\}/g, (_, k) => (v && v[k] !== undefined ? v[k] : ''));
|
||||||
const effortName = (e) => (e?.id === 'xhigh' ? 'Extra' : e?.name ?? e?.id);
|
const effortName = (e) => (e?.id === 'xhigh' ? 'Extra' : e?.name ?? e?.id);
|
||||||
|
|
||||||
// ---- host settings store (shared by every session's control) ----
|
// ---- host settings, one store per session (the advisor is chosen per session) ----
|
||||||
const settings = (() => {
|
function makeStore(sessionId) {
|
||||||
let value = null;
|
let value = null;
|
||||||
let inflight = null;
|
let inflight = null;
|
||||||
const subs = new Set();
|
const subs = new Set();
|
||||||
const emit = () => subs.forEach((f) => f());
|
const emit = () => subs.forEach((f) => f());
|
||||||
|
const url = `omp-advisor/settings?session=${encodeURIComponent(sessionId)}`;
|
||||||
async function call(init) {
|
async function call(init) {
|
||||||
const res = await fetch('omp-advisor/settings', { ...init, headers: { accept: 'application/json', ...(init?.body ? { 'content-type': 'application/json' } : {}) } });
|
const res = await fetch(url, { ...init, headers: { accept: 'application/json', ...(init?.body ? { 'content-type': 'application/json' } : {}) } });
|
||||||
let json = {};
|
let json = {};
|
||||||
try { json = await res.json(); } catch { /* empty */ }
|
try { json = await res.json(); } catch { /* empty */ }
|
||||||
if (!res.ok) throw new Error(json.message || res.statusText || String(res.status));
|
if (!res.ok) throw new Error(json.message || res.statusText || String(res.status));
|
||||||
@@ -112,11 +113,17 @@ window.__ModuleLoader__.load({
|
|||||||
try { value = await call({ method: 'POST', body: JSON.stringify(patch) }); emit(); } catch (e) { value = prev; emit(); throw e; }
|
try { value = await call({ method: 'POST', body: JSON.stringify(patch) }); emit(); } catch (e) { value = prev; emit(); throw e; }
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})();
|
}
|
||||||
|
const stores = new Map();
|
||||||
|
const storeFor = (sessionId) => {
|
||||||
|
let s = stores.get(sessionId);
|
||||||
|
if (!s) { s = makeStore(sessionId); stores.set(sessionId, s); }
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
function useSettings() {
|
function useSettings(store) {
|
||||||
const v = React.useSyncExternalStore(settings.subscribe, settings.get);
|
const v = React.useSyncExternalStore(store.subscribe, store.get);
|
||||||
React.useEffect(() => { if (v === null) settings.load(); }, [v]);
|
React.useEffect(() => { if (v === null) store.load(); }, [store, v]);
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +143,15 @@ window.__ModuleLoader__.load({
|
|||||||
return { left: Math.max(8, left), top: Math.max(8, Math.min(top, vh - m.height - 8)) };
|
return { left: Math.max(8, left), top: Math.max(8, Math.min(top, vh - m.height - 8)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdvisorPicker({ directory, load, t }) {
|
function AdvisorPicker(props) {
|
||||||
React.useEffect(installCss, []);
|
// Session-scoped slot; without an id (draft composer) there is nothing to configure yet.
|
||||||
const st = useSettings();
|
if (typeof props.sessionId !== 'string' || !props.sessionId) return null;
|
||||||
|
return h(AdvisorPickerFor, props);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdvisorPickerFor({ sessionId, directory, load, t }) {
|
||||||
|
const store = storeFor(sessionId);
|
||||||
|
const st = useSettings(store);
|
||||||
const dir = React.useSyncExternalStore((f) => directory.subscribe(f), () => directory.getSnapshot());
|
const dir = React.useSyncExternalStore((f) => directory.subscribe(f), () => directory.getSnapshot());
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [hover, setHover] = React.useState(null); // { key, el, model, group }
|
const [hover, setHover] = React.useState(null); // { key, el, model, group }
|
||||||
@@ -151,7 +164,7 @@ window.__ModuleLoader__.load({
|
|||||||
const subRef = React.useRef(null);
|
const subRef = React.useRef(null);
|
||||||
const hoverTimer = React.useRef(null);
|
const hoverTimer = React.useRef(null);
|
||||||
|
|
||||||
const enabled = st?.enabled !== false;
|
const enabled = st?.enabled === true; // off until this session turns it on (also while loading)
|
||||||
const own = !!(st?.provider && st?.model);
|
const own = !!(st?.provider && st?.model);
|
||||||
const groups = dir?.groups ?? [];
|
const groups = dir?.groups ?? [];
|
||||||
const current = own ? groups.find((g) => g.id === st.provider)?.models.find((m) => m.id === st.model) : null;
|
const current = own ? groups.find((g) => g.id === st.provider)?.models.find((m) => m.id === st.model) : null;
|
||||||
@@ -187,7 +200,7 @@ window.__ModuleLoader__.load({
|
|||||||
const choose = async (patch) => {
|
const choose = async (patch) => {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
try { await settings.save(patch); } catch (e) { setErr(String(e.message || e)); setOpen(true); }
|
try { await store.save(patch); } catch (e) { setErr(String(e.message || e)); setOpen(true); }
|
||||||
};
|
};
|
||||||
const pickModel = (group, model, effort) => choose({
|
const pickModel = (group, model, effort) => choose({
|
||||||
enabled: true, provider: group.id, model: model.id,
|
enabled: true, provider: group.id, model: model.id,
|
||||||
@@ -292,7 +305,7 @@ window.__ModuleLoader__.load({
|
|||||||
const directory = ctx.modelDirectories.directoryFor(sessionId);
|
const directory = ctx.modelDirectories.directoryFor(sessionId);
|
||||||
// Read-only use of the directory: the advisor never calls directory.select (that would
|
// Read-only use of the directory: the advisor never calls directory.select (that would
|
||||||
// switch the agent's own model).
|
// switch the agent's own model).
|
||||||
return { directory: directory.store, load: () => { directory.load().catch(() => {}); } };
|
return { sessionId, directory: directory.store, load: () => { directory.load().catch(() => {}); } };
|
||||||
},
|
},
|
||||||
}, AdvisorPicker));
|
}, AdvisorPicker));
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
- id: omp-advisor
|
- id: omp-advisor
|
||||||
name: '@local/omp-advisor'
|
name: '@local/omp-advisor'
|
||||||
config:
|
config:
|
||||||
enabled: true
|
# default for sessions not yet switched on in the composer (each session chooses its own)
|
||||||
|
enabled: false
|
||||||
# empty = same provider/model as the agent; set a cheaper/faster reviewer here
|
# empty = same provider/model as the agent; set a cheaper/faster reviewer here
|
||||||
provider: ''
|
provider: ''
|
||||||
model: ''
|
model: ''
|
||||||
|
|||||||
@@ -18,17 +18,36 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|||||||
export const name = 'omp-advisor'
|
export const name = 'omp-advisor'
|
||||||
export const inject = ['agents', 'llm']
|
export const inject = ['agents', 'llm']
|
||||||
|
|
||||||
/** Live settings chosen in the UI (enabled + advisor route), persisted next to the profile. */
|
/**
|
||||||
|
* Per-session advisor choices made in the UI: { [sessionId]: { enabled, provider, model,
|
||||||
|
* reasoningEffort, minLevel, at } }. A session without an entry uses the config defaults, which
|
||||||
|
* are OFF. The pre-1.2 global file (`omp-advisor.json`) is intentionally ignored so that no
|
||||||
|
* existing session silently stays on.
|
||||||
|
*/
|
||||||
function settingsFile() {
|
function settingsFile() {
|
||||||
const dir = process.env.DSH_PROFILE_DIR || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'profiles', 'web')
|
const dir = process.env.DSH_PROFILE_DIR || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'profiles', 'web')
|
||||||
return path.join(dir, 'omp-advisor.json')
|
return path.join(dir, 'omp-advisor', 'sessions.json')
|
||||||
}
|
}
|
||||||
function loadSettings() { try { return JSON.parse(fs.readFileSync(settingsFile(), 'utf8')) } catch { return {} } }
|
const MAX_SESSIONS = 2000
|
||||||
function saveSettings(v) {
|
function loadSessions() {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(fs.readFileSync(settingsFile(), 'utf8'))
|
||||||
|
return v && typeof v === 'object' && !Array.isArray(v) ? v : {}
|
||||||
|
} catch { return {} }
|
||||||
|
}
|
||||||
|
function saveSessions(all) {
|
||||||
const f = settingsFile()
|
const f = settingsFile()
|
||||||
fs.mkdirSync(path.dirname(f), { recursive: true })
|
fs.mkdirSync(path.dirname(f), { recursive: true })
|
||||||
fs.writeFileSync(f, JSON.stringify(v, null, 2))
|
// Keep the most recently changed sessions only.
|
||||||
|
const entries = Object.entries(all)
|
||||||
|
const kept = entries.length > MAX_SESSIONS
|
||||||
|
? Object.fromEntries(entries.sort((a, b) => (b[1]?.at ?? 0) - (a[1]?.at ?? 0)).slice(0, MAX_SESSIONS))
|
||||||
|
: all
|
||||||
|
const tmp = `${f}.${process.pid}.tmp`
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(kept, null, 1))
|
||||||
|
fs.renameSync(tmp, f) // atomic replace: a crash never leaves half a file
|
||||||
}
|
}
|
||||||
|
const validSessionId = (s) => typeof s === 'string' && s.length > 0 && s.length <= 200 && /^[\w.:-]+$/.test(s)
|
||||||
|
|
||||||
const SOURCE = { kind: 'omp-advisor' }
|
const SOURCE = { kind: 'omp-advisor' }
|
||||||
|
|
||||||
@@ -41,7 +60,7 @@ Be specific and short (max 3 sentences), reference files/commands concretely, an
|
|||||||
|
|
||||||
export function apply(ctx, config = {}) {
|
export function apply(ctx, config = {}) {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
enabled: true,
|
enabled: false, // default for every session until it is turned on in the composer
|
||||||
provider: '', // empty = same provider as the agent
|
provider: '', // empty = same provider as the agent
|
||||||
model: '', // empty = same model as the agent
|
model: '', // empty = same model as the agent
|
||||||
reasoningEffort: undefined,
|
reasoningEffort: undefined,
|
||||||
@@ -55,14 +74,20 @@ export function apply(ctx, config = {}) {
|
|||||||
...config,
|
...config,
|
||||||
}
|
}
|
||||||
const LEVEL = { ok: 0, note: 1, concern: 2, blocker: 3 }
|
const LEVEL = { ok: 0, note: 1, concern: 2, blocker: 3 }
|
||||||
let live = loadSettings() // { enabled?, provider?, model?, reasoningEffort? }
|
let sessions = loadSessions()
|
||||||
const eff = () => ({
|
const sessionIdOf = (agent) => agent?.id ?? agent?.session?.header?.id
|
||||||
|
/** Effective settings for one session: its own UI choice over the config defaults. */
|
||||||
|
const eff = (sessionId) => {
|
||||||
|
const live = (sessionId && sessions[sessionId]) || {}
|
||||||
|
return {
|
||||||
enabled: live.enabled ?? cfg.enabled,
|
enabled: live.enabled ?? cfg.enabled,
|
||||||
provider: live.provider ?? cfg.provider,
|
provider: live.provider ?? cfg.provider,
|
||||||
model: live.model ?? cfg.model,
|
model: live.model ?? cfg.model,
|
||||||
reasoningEffort: live.reasoningEffort === null ? undefined : (live.reasoningEffort ?? cfg.reasoningEffort),
|
reasoningEffort: live.reasoningEffort === null ? undefined : (live.reasoningEffort ?? cfg.reasoningEffort),
|
||||||
})
|
minLevel: live.minLevel ?? cfg.minLevel,
|
||||||
const minLevel = () => LEVEL[live.minLevel ?? cfg.minLevel] ?? 1
|
}
|
||||||
|
}
|
||||||
|
const minLevel = (sessionId) => LEVEL[eff(sessionId).minLevel] ?? 1
|
||||||
const installed = new Map()
|
const installed = new Map()
|
||||||
|
|
||||||
function install(agent) {
|
function install(agent) {
|
||||||
@@ -75,7 +100,7 @@ export function apply(ctx, config = {}) {
|
|||||||
disposers.push(agent.ctx.on('agent/pre-step', async (payload, next) => {
|
disposers.push(agent.ctx.on('agent/pre-step', async (payload, next) => {
|
||||||
const decision = await next()
|
const decision = await next()
|
||||||
if (decision.kind !== 'enter') return decision
|
if (decision.kind !== 'enter') return decision
|
||||||
if (!eff().enabled) { st.queue.length = 0; return decision }
|
if (!eff(sessionIdOf(agent)).enabled) { st.queue.length = 0; return decision }
|
||||||
st.step++
|
st.step++
|
||||||
if (payload.step > 1 && st.step % cfg.everyNSteps === 0 && !st.pending) {
|
if (payload.step > 1 && st.step % cfg.everyNSteps === 0 && !st.pending) {
|
||||||
st.pending = review(agent, st, payload.signal).finally(() => { st.pending = null })
|
st.pending = review(agent, st, payload.signal).finally(() => { st.pending = null })
|
||||||
@@ -89,7 +114,7 @@ export function apply(ctx, config = {}) {
|
|||||||
|
|
||||||
// At the end of a turn: deliver late advice; a blocker forces one more step.
|
// At the end of a turn: deliver late advice; a blocker forces one more step.
|
||||||
disposers.push(agent.ctx.on('agent/turn-stopping', async ({ signal }) => {
|
disposers.push(agent.ctx.on('agent/turn-stopping', async ({ signal }) => {
|
||||||
if (!eff().enabled) return
|
if (!eff(sessionIdOf(agent)).enabled) return
|
||||||
if (st.pending) {
|
if (st.pending) {
|
||||||
let t
|
let t
|
||||||
await Promise.race([st.pending, new Promise((r) => { t = setTimeout(r, cfg.blocking ? cfg.timeoutMs : 4000) })]).finally(() => clearTimeout(t))
|
await Promise.race([st.pending, new Promise((r) => { t = setTimeout(r, cfg.blocking ? cfg.timeoutMs : 4000) })]).finally(() => clearTimeout(t))
|
||||||
@@ -114,7 +139,7 @@ export function apply(ctx, config = {}) {
|
|||||||
async function review(agent, st, signal) {
|
async function review(agent, st, signal) {
|
||||||
try {
|
try {
|
||||||
const header = agent.session.requestHeader?.()
|
const header = agent.session.requestHeader?.()
|
||||||
const e = eff()
|
const e = eff(sessionIdOf(agent))
|
||||||
// A half-set route (provider without model) falls back to the agent's model as a whole.
|
// A half-set route (provider without model) falls back to the agent's model as a whole.
|
||||||
const own = e.provider && e.model
|
const own = e.provider && e.model
|
||||||
const provider = own ? e.provider : header?.config?.provider
|
const provider = own ? e.provider : header?.config?.provider
|
||||||
@@ -143,7 +168,7 @@ export function apply(ctx, config = {}) {
|
|||||||
const v = parseVerdict(text)
|
const v = parseVerdict(text)
|
||||||
if (!v) return
|
if (!v) return
|
||||||
st.history.push(v)
|
st.history.push(v)
|
||||||
if ((LEVEL[v.verdict] ?? 0) >= minLevel() && LEVEL[v.verdict] > 0) {
|
if ((LEVEL[v.verdict] ?? 0) >= minLevel(sessionIdOf(agent)) && LEVEL[v.verdict] > 0) {
|
||||||
if (st.history.slice(0, -1).some((h) => h.message === v.message)) return
|
if (st.history.slice(0, -1).some((h) => h.message === v.message)) return
|
||||||
st.queue.push(v)
|
st.queue.push(v)
|
||||||
}
|
}
|
||||||
@@ -197,7 +222,7 @@ export function apply(ctx, config = {}) {
|
|||||||
const connection = wctx.connection
|
const connection = wctx.connection
|
||||||
const send = (res, code, body) => { res.statusCode = code; res.setHeader('content-type', 'application/json; charset=utf-8'); res.setHeader('cache-control', 'no-store'); res.end(JSON.stringify(body)) }
|
const send = (res, code, body) => { res.statusCode = code; res.setHeader('content-type', 'application/json; charset=utf-8'); res.setHeader('cache-control', 'no-store'); res.end(JSON.stringify(body)) }
|
||||||
const readBody = async (req) => { let b = ''; for await (const c of req) { b += c; if (b.length > 16384) throw new Error('body too large') } return b ? JSON.parse(b) : {} }
|
const readBody = async (req) => { let b = ''; for await (const c of req) { b += c; if (b.length > 16384) throw new Error('body too large') } return b ? JSON.parse(b) : {} }
|
||||||
const state = () => ({ ...eff(), configured: { provider: cfg.provider || null, model: cfg.model || null }, minLevel: live.minLevel ?? cfg.minLevel })
|
const state = (sessionId) => ({ sessionId, ...eff(sessionId), configured: { provider: cfg.provider || null, model: cfg.model || null, enabled: !!cfg.enabled } })
|
||||||
wctx.effect(() => webServer.register({
|
wctx.effect(() => webServer.register({
|
||||||
kind: 'exact',
|
kind: 'exact',
|
||||||
path: '/omp-advisor/settings',
|
path: '/omp-advisor/settings',
|
||||||
@@ -205,18 +230,22 @@ export function apply(ctx, config = {}) {
|
|||||||
const rej = connection.requestRejection(req)
|
const rej = connection.requestRejection(req)
|
||||||
if (rej !== undefined) return send(res, rej, { code: 'rejected' })
|
if (rej !== undefined) return send(res, rej, { code: 'rejected' })
|
||||||
try {
|
try {
|
||||||
if (req.method === 'GET') return send(res, 200, state())
|
// Settings are per session: ?session=<id> on every call.
|
||||||
|
const sessionId = new URL(req.url ?? '/', 'http://x').searchParams.get('session')
|
||||||
|
if (!validSessionId(sessionId)) return send(res, 400, { code: 'error', message: 'session query parameter is required' })
|
||||||
|
if (req.method === 'GET') return send(res, 200, state(sessionId))
|
||||||
if (req.method !== 'POST') { res.statusCode = 405; return res.end() }
|
if (req.method !== 'POST') { res.statusCode = 405; return res.end() }
|
||||||
const b = await readBody(req)
|
const b = await readBody(req)
|
||||||
const next = { ...live }
|
const next = { ...(sessions[sessionId] ?? {}) }
|
||||||
if (typeof b.enabled === 'boolean') next.enabled = b.enabled
|
if (typeof b.enabled === 'boolean') next.enabled = b.enabled
|
||||||
if ('provider' in b) next.provider = typeof b.provider === 'string' && b.provider ? b.provider : ''
|
if ('provider' in b) next.provider = typeof b.provider === 'string' && b.provider ? b.provider : ''
|
||||||
if ('model' in b) next.model = typeof b.model === 'string' && b.model ? b.model : ''
|
if ('model' in b) next.model = typeof b.model === 'string' && b.model ? b.model : ''
|
||||||
if ('reasoningEffort' in b) next.reasoningEffort = typeof b.reasoningEffort === 'string' && b.reasoningEffort ? b.reasoningEffort : null
|
if ('reasoningEffort' in b) next.reasoningEffort = typeof b.reasoningEffort === 'string' && b.reasoningEffort ? b.reasoningEffort : null
|
||||||
if (typeof b.minLevel === 'string' && b.minLevel in LEVEL && b.minLevel !== 'ok') next.minLevel = b.minLevel
|
if (typeof b.minLevel === 'string' && b.minLevel in LEVEL && b.minLevel !== 'ok') next.minLevel = b.minLevel
|
||||||
live = next
|
next.at = Date.now()
|
||||||
saveSettings(live)
|
sessions = { ...sessions, [sessionId]: next }
|
||||||
return send(res, 200, state())
|
saveSessions(sessions)
|
||||||
|
return send(res, 200, state(sessionId))
|
||||||
} catch (e) { return send(res, 400, { code: 'error', message: String(e?.message ?? e) }) }
|
} catch (e) { return send(res, 400, { code: 'error', message: String(e?.message ?? e) }) }
|
||||||
},
|
},
|
||||||
}), 'omp-advisor: settings route')
|
}), 'omp-advisor: settings route')
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@local/omp-advisor",
|
"name": "@local/omp-advisor",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"description": "oh-my-pi style advisor for DSH: a second model reviews every agent step and injects notes, concerns or blockers.",
|
"description": "oh-my-pi style advisor for DSH: a second model reviews every agent step and injects notes, concerns or blockers.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+56
-10
@@ -6,14 +6,23 @@ import path from 'node:path'
|
|||||||
import { Readable } from 'node:stream'
|
import { Readable } from 'node:stream'
|
||||||
import * as plugin from '../index.js'
|
import * as plugin from '../index.js'
|
||||||
|
|
||||||
// Settings persist to <DSH_PROFILE_DIR>/omp-advisor.json; keep tests away from the real profile.
|
// Settings persist to <DSH_PROFILE_DIR>/omp-advisor/sessions.json; keep tests away from the real profile.
|
||||||
const PROFILE = fs.mkdtempSync(path.join(os.tmpdir(), 'omp-advisor-test-'))
|
const PROFILE = fs.mkdtempSync(path.join(os.tmpdir(), 'omp-advisor-test-'))
|
||||||
process.env.DSH_PROFILE_DIR = PROFILE
|
process.env.DSH_PROFILE_DIR = PROFILE
|
||||||
const SETTINGS = path.join(PROFILE, 'omp-advisor.json')
|
const SETTINGS = path.join(PROFILE, 'omp-advisor', 'sessions.json')
|
||||||
test.after(() => fs.rmSync(PROFILE, { recursive: true, force: true }))
|
test.after(() => fs.rmSync(PROFILE, { recursive: true, force: true }))
|
||||||
|
|
||||||
function harness(replies, config = {}) {
|
/**
|
||||||
|
* @param config plugin config overrides
|
||||||
|
* @param opts { id: session id (default 'session-a'), on: pre-enable this session (default true) }
|
||||||
|
*/
|
||||||
|
function harness(replies, config = {}, opts = {}) {
|
||||||
|
const sessionId = opts.id ?? 'session-a'
|
||||||
fs.rmSync(SETTINGS, { force: true }) // live settings load in apply(): never leak between tests
|
fs.rmSync(SETTINGS, { force: true }) // live settings load in apply(): never leak between tests
|
||||||
|
if (opts.on !== false) {
|
||||||
|
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true })
|
||||||
|
fs.writeFileSync(SETTINGS, JSON.stringify({ [sessionId]: { enabled: true, at: 1 } }))
|
||||||
|
}
|
||||||
const listeners = new Map()
|
const listeners = new Map()
|
||||||
const on = (n, f) => { listeners.set(n, [...(listeners.get(n) ?? []), f]); return () => listeners.set(n, listeners.get(n).filter((x) => x !== f)) }
|
const on = (n, f) => { listeners.set(n, [...(listeners.get(n) ?? []), f]); return () => listeners.set(n, listeners.get(n).filter((x) => x !== f)) }
|
||||||
const prompts = []
|
const prompts = []
|
||||||
@@ -34,7 +43,7 @@ function harness(replies, config = {}) {
|
|||||||
deriveEventMessage: (e) => e.msg,
|
deriveEventMessage: (e) => e.msg,
|
||||||
}
|
}
|
||||||
const steered = []
|
const steered = []
|
||||||
const agent = { session, ctx: { on }, steer: (m) => steered.push(m) }
|
const agent = { id: sessionId, session: { ...session, header: { ...session.header, id: sessionId } }, ctx: { on }, steer: (m) => steered.push(m) }
|
||||||
const routes = new Map()
|
const routes = new Map()
|
||||||
const webServer = { register: (r) => { routes.set(r.path, r.handler); return () => routes.delete(r.path) } }
|
const webServer = { register: (r) => { routes.set(r.path, r.handler); return () => routes.delete(r.path) } }
|
||||||
const connection = { requestRejection: (req) => (req.headers?.['x-reject'] ? 401 : undefined) }
|
const connection = { requestRejection: (req) => (req.headers?.['x-reject'] ? 401 : undefined) }
|
||||||
@@ -52,11 +61,12 @@ function harness(replies, config = {}) {
|
|||||||
const stopTurn = async () => { for (const f of listeners.get('agent/turn-stopping') ?? []) await f({ agent, turn: 1 }) }
|
const stopTurn = async () => { for (const f of listeners.get('agent/turn-stopping') ?? []) await f({ agent, turn: 1 }) }
|
||||||
const add = (role, content) => events.push({ msg: { role, content } })
|
const add = (role, content) => events.push({ msg: { role, content } })
|
||||||
/** Call the settings route like the web client does. */
|
/** Call the settings route like the web client does. */
|
||||||
const http = async (method, body, headers = {}) => {
|
const http = async (method, body, headers = {}, sid = sessionId) => {
|
||||||
const handler = routes.get('/omp-advisor/settings')
|
const handler = routes.get('/omp-advisor/settings')
|
||||||
const req = Readable.from(body === undefined ? [] : [JSON.stringify(body)])
|
const req = Readable.from(body === undefined ? [] : [JSON.stringify(body)])
|
||||||
req.method = method
|
req.method = method
|
||||||
req.headers = headers
|
req.headers = headers
|
||||||
|
req.url = sid === null ? '/omp-advisor/settings' : `/omp-advisor/settings?session=${encodeURIComponent(sid)}`
|
||||||
let status = 200; let out = ''
|
let status = 200; let out = ''
|
||||||
const res = { statusCode: 200, setHeader() {}, end: (s) => { status = res.statusCode; out = s ?? '' } }
|
const res = { statusCode: 200, setHeader() {}, end: (s) => { status = res.statusCode; out = s ?? '' } }
|
||||||
await handler(req, res)
|
await handler(req, res)
|
||||||
@@ -111,20 +121,56 @@ test('minLevel filters notes and duplicates are suppressed', async () => {
|
|||||||
assert.equal((await h.preStep(4)).messages.length, 0) // identical concern suppressed
|
assert.equal((await h.preStep(4)).messages.length, 0) // identical concern suppressed
|
||||||
})
|
})
|
||||||
|
|
||||||
test('settings route: GET/POST, rejection, persistence', async () => {
|
test('settings route: per-session GET/POST, rejection, missing session, persistence', async () => {
|
||||||
fs.rmSync(SETTINGS, { force: true })
|
const h = harness([], {}, { on: false })
|
||||||
const h = harness([])
|
|
||||||
assert.equal((await h.http('GET', undefined, { 'x-reject': '1' })).status, 401)
|
assert.equal((await h.http('GET', undefined, { 'x-reject': '1' })).status, 401)
|
||||||
|
assert.equal((await h.http('GET', undefined, {}, null)).status, 400) // no ?session=
|
||||||
|
assert.equal((await h.http('GET', undefined, {}, '../etc')).status, 400) // invalid id
|
||||||
const g = await h.http('GET')
|
const g = await h.http('GET')
|
||||||
assert.equal(g.status, 200)
|
assert.equal(g.status, 200)
|
||||||
assert.equal(g.json.enabled, true)
|
assert.equal(g.json.enabled, false) // off by default
|
||||||
|
assert.equal(g.json.sessionId, 'session-a')
|
||||||
const p = await h.http('POST', { enabled: true, provider: 'omni', model: 'cheap-model', reasoningEffort: 'low' })
|
const p = await h.http('POST', { enabled: true, provider: 'omni', model: 'cheap-model', reasoningEffort: 'low' })
|
||||||
assert.equal(p.json.model, 'cheap-model')
|
assert.equal(p.json.model, 'cheap-model')
|
||||||
assert.equal(p.json.reasoningEffort, 'low')
|
assert.equal(p.json.reasoningEffort, 'low')
|
||||||
assert.deepEqual(JSON.parse(fs.readFileSync(SETTINGS, 'utf8')), { enabled: true, provider: 'omni', model: 'cheap-model', reasoningEffort: 'low' })
|
const stored = JSON.parse(fs.readFileSync(SETTINGS, 'utf8'))
|
||||||
|
assert.deepEqual(Object.keys(stored), ['session-a'])
|
||||||
|
assert.equal(stored['session-a'].model, 'cheap-model')
|
||||||
|
assert.equal(typeof stored['session-a'].at, 'number')
|
||||||
assert.equal((await h.http('PUT', {})).status, 405)
|
assert.equal((await h.http('PUT', {})).status, 405)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('default is off: no review for a session that never turned the advisor on', async () => {
|
||||||
|
const h = harness(['{"verdict":"concern","message":"should never be asked"}'], {}, { on: false })
|
||||||
|
h.add('user', [{ type: 'text', text: 'task' }])
|
||||||
|
assert.equal((await h.preStep(2)).messages.length, 0)
|
||||||
|
assert.equal(h.prompts.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the old global omp-advisor.json is ignored (existing sessions stay off)', async () => {
|
||||||
|
fs.writeFileSync(path.join(PROFILE, 'omp-advisor.json'), JSON.stringify({ enabled: true, model: 'x' }))
|
||||||
|
const h = harness(['{"verdict":"ok","message":""}'], {}, { on: false })
|
||||||
|
h.add('user', [{ type: 'text', text: 'task' }])
|
||||||
|
await h.preStep(2)
|
||||||
|
assert.equal(h.prompts.length, 0)
|
||||||
|
assert.equal((await h.http('GET')).json.enabled, false)
|
||||||
|
fs.rmSync(path.join(PROFILE, 'omp-advisor.json'), { force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sessions are independent: turning one on does not affect another', async () => {
|
||||||
|
const h = harness(['{"verdict":"ok","message":""}', '{"verdict":"ok","message":""}'], {}, { on: false })
|
||||||
|
h.add('user', [{ type: 'text', text: 'task' }])
|
||||||
|
await h.http('POST', { enabled: true, model: 'other-model', provider: 'omni' }, {}, 'session-b')
|
||||||
|
await h.preStep(2) // this agent is session-a
|
||||||
|
assert.equal(h.prompts.length, 0)
|
||||||
|
assert.equal((await h.http('GET', undefined, {}, 'session-b')).json.model, 'other-model')
|
||||||
|
assert.equal((await h.http('GET')).json.enabled, false)
|
||||||
|
await h.http('POST', { enabled: true })
|
||||||
|
await h.preStep(3)
|
||||||
|
assert.equal(h.prompts.length, 1)
|
||||||
|
assert.equal(h.prompts[0].model, 'main-model') // session-a uses the agent model, not session-b's
|
||||||
|
})
|
||||||
|
|
||||||
test('UI choice applies on the next review without restart; off disables reviews', async () => {
|
test('UI choice applies on the next review without restart; off disables reviews', async () => {
|
||||||
fs.rmSync(SETTINGS, { force: true })
|
fs.rmSync(SETTINGS, { force: true })
|
||||||
const h = harness(['{"verdict":"ok","message":""}', '{"verdict":"ok","message":""}'])
|
const h = harness(['{"verdict":"ok","message":""}', '{"verdict":"ok","message":""}'])
|
||||||
|
|||||||
Reference in new issue
Block a user