dsh-biocore 0.1.5 → 0.1.7
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/lib/index.js +2 -1
- package/lib/modules/core-turn.js +22 -3
- package/lib/modules/data-dir.js +90 -5
- package/package.json +1 -1
- package/python/biocore_runtime/pipeline.py +34 -1
package/lib/index.js
CHANGED
|
@@ -28,7 +28,7 @@ import { TRACE_ROUTE_PATH, defaultStoreDir, handleTraceRoute } from './modules/t
|
|
|
28
28
|
import { queueSessionTurn } from './modules/turn-queue.js'
|
|
29
29
|
import { anySwitchOn } from './modules/expression-inject.js'
|
|
30
30
|
import { makeCoreSessionListener, registerCoreTurn } from './modules/core-turn.js'
|
|
31
|
-
import { PLUGIN_DATA_DIR, resolveDataDir } from './modules/data-dir.js'
|
|
31
|
+
import { migrateLegacyPluginData, PLUGIN_DATA_DIR, resolveDataDir } from './modules/data-dir.js'
|
|
32
32
|
import {
|
|
33
33
|
PANEL_MOUNT_PATH,
|
|
34
34
|
PANEL_ROUTE_PREFIX,
|
|
@@ -761,6 +761,7 @@ export async function handleState(req, res, stateDir, options) {
|
|
|
761
761
|
|
|
762
762
|
export function apply(ctx, config) {
|
|
763
763
|
const dataDir = resolveDataDir(config)
|
|
764
|
+
try { migrateLegacyPluginData(dataDir) } catch { /* best-effort data migration */ }
|
|
764
765
|
try { migratePersonaToShared(dataDir) } catch { /* best-effort migration */ }
|
|
765
766
|
const disposers = []
|
|
766
767
|
const keep = (dispose) => {
|
package/lib/modules/core-turn.js
CHANGED
|
@@ -94,6 +94,17 @@ export function diagnosticsFromState(state, userText) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/** Final assistant text: core clean_text wins, JS sanitizer is the fallback.
|
|
98
|
+
* sanitizeEnabled=false keeps the original (user disabled the switch). */
|
|
99
|
+
export function chooseCleanReply(original, coreCleanText, options) {
|
|
100
|
+
const opts = options || {}
|
|
101
|
+
const source = String(original == null ? '' : original)
|
|
102
|
+
if (opts.sanitizeEnabled === false) return source
|
|
103
|
+
if (typeof coreCleanText === 'string') return coreCleanText
|
|
104
|
+
const sanitize = typeof opts.sanitize === 'function' ? opts.sanitize : sanitizeReply
|
|
105
|
+
return String(sanitize(source))
|
|
106
|
+
}
|
|
107
|
+
|
|
97
108
|
export function turnStartPayload(options) {
|
|
98
109
|
const opts = options || {}
|
|
99
110
|
const expression = opts.expression && typeof opts.expression === 'object'
|
|
@@ -218,6 +229,14 @@ export function makeCoreSessionListener(options) {
|
|
|
218
229
|
})
|
|
219
230
|
const result = await runAction('turn_end', payload)
|
|
220
231
|
if (!result || result.ok !== true) return
|
|
232
|
+
const reply = result.reply && typeof result.reply === 'object' ? result.reply : {}
|
|
233
|
+
const coreCleanText = typeof reply.clean_text === 'string'
|
|
234
|
+
? reply.clean_text
|
|
235
|
+
: (typeof result.clean_text === 'string' ? result.clean_text : null)
|
|
236
|
+
const finalText = chooseCleanReply(original, coreCleanText, {
|
|
237
|
+
sanitizeEnabled,
|
|
238
|
+
sanitize: sanitizeReply,
|
|
239
|
+
})
|
|
221
240
|
const gate = result.gate && typeof result.gate === 'object' ? result.gate : {}
|
|
222
241
|
const action = stringId(gate.action || 'pass')
|
|
223
242
|
if ((action === 'replace' || action === 'truncate')
|
|
@@ -228,9 +247,9 @@ export function makeCoreSessionListener(options) {
|
|
|
228
247
|
+ ' blocked=' + (gate.blocked === true ? '1' : '0')
|
|
229
248
|
+ ' rule=' + stringId(gate.rule || '')
|
|
230
249
|
+ ' chars=' + original.length + '->' + gate.text.length)
|
|
231
|
-
} else if (
|
|
232
|
-
// No gate rewrite:
|
|
233
|
-
applyGateText(event,
|
|
250
|
+
} else if (finalText !== original) {
|
|
251
|
+
// No gate rewrite: write the core-cleaned / JS-fallback text back.
|
|
252
|
+
applyGateText(event, finalText)
|
|
234
253
|
}
|
|
235
254
|
if (result.trace_file) {
|
|
236
255
|
logLine(log, CORE_TRACE_PREFIX + ' session=' + sessionId
|
package/lib/modules/data-dir.js
CHANGED
|
@@ -4,21 +4,24 @@
|
|
|
4
4
|
* <dataDir>/sessions/<session_id>.json (per-session BioCore state)
|
|
5
5
|
* <dataDir>/flags.json (four switches)
|
|
6
6
|
*
|
|
7
|
-
* Resolution order
|
|
7
|
+
* Resolution order:
|
|
8
8
|
* 1. --profile <name> / --profile=<name> from process.argv
|
|
9
9
|
* -> <homedir>/.dsh/profiles/<name>/data, only when that profile dir exists
|
|
10
10
|
* 2. config.dataDir (cordis insert config)
|
|
11
11
|
* 3. BIOCORE_UI_DATA_DIR
|
|
12
12
|
* 4. <DSH_PROFILE_DIR>/data -- only when it is a real DSH profile directory
|
|
13
13
|
* (contains package.json or dsh.yml)
|
|
14
|
-
* 5. <
|
|
14
|
+
* 5. <home>/.dsh/profiles/web/data -- when profiles/web/package.json exists
|
|
15
|
+
* 6. <home>/.dsh/profiles/<only>/data -- exactly one profile under profiles/
|
|
16
|
+
* 7. <plugin>/data -- last resort, logs __bioDataDirFallback
|
|
17
|
+
* (never cwd; previous node_modules data is migrated, not deleted)
|
|
15
18
|
*
|
|
16
19
|
* Why argv first: DSH passes the serving process its own --profile flag, but
|
|
17
20
|
* DSH_PROFILE_DIR in the environment can point at the *default* profile (web)
|
|
18
21
|
* even when the instance runs --profile web-bio. Trusting the env first wrote
|
|
19
22
|
* session state into the wrong profile's data dir.
|
|
20
23
|
*/
|
|
21
|
-
import { existsSync } from 'node:fs'
|
|
24
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'
|
|
22
25
|
import { homedir } from 'node:os'
|
|
23
26
|
import { fileURLToPath } from 'node:url'
|
|
24
27
|
import { dirname, join, resolve } from 'node:path'
|
|
@@ -28,6 +31,10 @@ export const PROFILE_DIR_ENV = 'DSH_PROFILE_DIR'
|
|
|
28
31
|
export const PROFILE_MARKERS = Object.freeze(['package.json', 'dsh.yml'])
|
|
29
32
|
/** Fallback: the plugin checkout's own data/ directory. */
|
|
30
33
|
export const PLUGIN_DATA_DIR = fileURLToPath(new URL('../../data', import.meta.url))
|
|
34
|
+
export const FALLBACK_WARN_PREFIX = '__bioDataDirFallback'
|
|
35
|
+
export const MIGRATE_LOG_PREFIX = '__bioDataMigrated'
|
|
36
|
+
|
|
37
|
+
let warnedFallback = false
|
|
31
38
|
|
|
32
39
|
function text(value) {
|
|
33
40
|
return typeof value === 'string' ? value.trim() : ''
|
|
@@ -69,6 +76,71 @@ export function isProfileDir(dir) {
|
|
|
69
76
|
}
|
|
70
77
|
}
|
|
71
78
|
|
|
79
|
+
/** <home>/.dsh/profiles */
|
|
80
|
+
export function profilesRootDir(homeDir) {
|
|
81
|
+
const home = text(homeDir) || homedir()
|
|
82
|
+
return join(resolve(home), '.dsh', 'profiles')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** All real profile names directly under <home>/.dsh/profiles. */
|
|
86
|
+
export function listProfiles(homeDir) {
|
|
87
|
+
const root = profilesRootDir(homeDir)
|
|
88
|
+
let names = []
|
|
89
|
+
try {
|
|
90
|
+
names = readdirSync(root, { withFileTypes: true })
|
|
91
|
+
.filter((entry) => entry && entry.isDirectory())
|
|
92
|
+
.map((entry) => entry.name)
|
|
93
|
+
} catch { return [] }
|
|
94
|
+
return names.filter((name) => isProfileDir(join(root, name)))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function warnPluginFallback(path) {
|
|
98
|
+
if (warnedFallback) return
|
|
99
|
+
warnedFallback = true
|
|
100
|
+
try {
|
|
101
|
+
console.error(FALLBACK_WARN_PREFIX + ' using plugin data dir=' + path
|
|
102
|
+
+ ' reason=no-profile-dir (writes will live inside the plugin package)')
|
|
103
|
+
} catch { /* logging must not throw */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Copy existing legacy plugin data into the resolved profile dataDir.
|
|
107
|
+
* Missing files are copied; existing files are never overwritten; the old
|
|
108
|
+
* plugin copy stays in place for rollback. */
|
|
109
|
+
export function migrateLegacyPluginData(dataDir, legacyDir) {
|
|
110
|
+
const target = text(dataDir)
|
|
111
|
+
const source = text(legacyDir) || PLUGIN_DATA_DIR
|
|
112
|
+
const result = { migrated: false, copied: 0, target, source }
|
|
113
|
+
if (!target || resolve(target) === resolve(source)) return result
|
|
114
|
+
// Only migrate into <profile>/data when the parent is a real profile root.
|
|
115
|
+
const parent = dirname(resolve(target))
|
|
116
|
+
if (!isProfileDir(parent)) return result
|
|
117
|
+
const copyIfMissing = (from, to) => {
|
|
118
|
+
try {
|
|
119
|
+
if (!existsSync(from) || existsSync(to)) return false
|
|
120
|
+
mkdirSync(dirname(to), { recursive: true })
|
|
121
|
+
copyFileSync(from, to)
|
|
122
|
+
result.copied += 1
|
|
123
|
+
return true
|
|
124
|
+
} catch { return false }
|
|
125
|
+
}
|
|
126
|
+
const files = ['flags.json', 'persona_profile.json', 'persona.md', 'config.json']
|
|
127
|
+
for (const name of files) copyIfMissing(join(source, name), join(target, name))
|
|
128
|
+
try {
|
|
129
|
+
const sessions = readdirSync(join(source, 'sessions'))
|
|
130
|
+
for (const name of sessions) {
|
|
131
|
+
copyIfMissing(join(source, 'sessions', name), join(target, 'sessions', name))
|
|
132
|
+
}
|
|
133
|
+
} catch { /* no legacy sessions */ }
|
|
134
|
+
result.migrated = result.copied > 0
|
|
135
|
+
if (result.migrated) {
|
|
136
|
+
try {
|
|
137
|
+
console.error(MIGRATE_LOG_PREFIX + ' from=' + source + ' to=' + target
|
|
138
|
+
+ ' copied=' + result.copied)
|
|
139
|
+
} catch { /* logging must not throw */ }
|
|
140
|
+
}
|
|
141
|
+
return result
|
|
142
|
+
}
|
|
143
|
+
|
|
72
144
|
/**
|
|
73
145
|
* Resolve the one directory that owns flags.json and sessions/.
|
|
74
146
|
* @param {object} [config] plugin config (config.dataDir)
|
|
@@ -103,5 +175,18 @@ export function resolveDataDir(config, env, argv, homeDir) {
|
|
|
103
175
|
if (isProfileDir(candidate)) return join(candidate, 'data')
|
|
104
176
|
}
|
|
105
177
|
|
|
106
|
-
|
|
107
|
-
|
|
178
|
+
// 5) default web profile, when it is a real profile.
|
|
179
|
+
const webProfile = join(profilesRootDir(homeDir), 'web')
|
|
180
|
+
if (isProfileDir(webProfile)) return join(webProfile, 'data')
|
|
181
|
+
|
|
182
|
+
// 6) exactly one profile -> deterministic; multiple profiles are ambiguous.
|
|
183
|
+
const profiles = listProfiles(homeDir)
|
|
184
|
+
if (profiles.length === 1) {
|
|
185
|
+
return join(profilesRootDir(homeDir), profiles[0], 'data')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 7) last resort: the plugin package itself.
|
|
189
|
+
const fallback = resolve(PLUGIN_DATA_DIR)
|
|
190
|
+
warnPluginFallback(fallback)
|
|
191
|
+
return fallback
|
|
192
|
+
}
|
package/package.json
CHANGED
|
@@ -96,6 +96,11 @@ try:
|
|
|
96
96
|
except Exception: # pragma: no cover
|
|
97
97
|
_rerank_mod = None
|
|
98
98
|
|
|
99
|
+
try:
|
|
100
|
+
from biocore_model import sanitize as _sanitize
|
|
101
|
+
except Exception: # pragma: no cover
|
|
102
|
+
_sanitize = None
|
|
103
|
+
|
|
99
104
|
try: # self-layer modules (all pure; optional so a stripped checkout still runs)
|
|
100
105
|
from biocore_model import x_directives as _x_directives
|
|
101
106
|
from biocore_model import self_intent as _self_intent
|
|
@@ -1794,10 +1799,35 @@ def dispatch(action, payload=None):
|
|
|
1794
1799
|
return bad("missing session_id")
|
|
1795
1800
|
if "assistant_text" not in data:
|
|
1796
1801
|
return bad("missing assistant_text")
|
|
1797
|
-
|
|
1802
|
+
raw_assistant = clean_text(data.get("assistant_text"))
|
|
1803
|
+
do_sanitize = _as_bool(data.get("sanitize_reply"), True)
|
|
1804
|
+
cleaned = raw_assistant
|
|
1805
|
+
sanitized = False
|
|
1806
|
+
removed_tags = 0
|
|
1807
|
+
if do_sanitize and _sanitize is not None:
|
|
1808
|
+
stats = getattr(_sanitize, "STATS", {}) or {}
|
|
1809
|
+
before = (int(stats.get("tag_stripped", 0) or 0),
|
|
1810
|
+
int(stats.get("json_stripped", 0) or 0),
|
|
1811
|
+
int(stats.get("fenced_stripped", 0) or 0))
|
|
1812
|
+
try:
|
|
1813
|
+
cleaned = _sanitize.clean(raw_assistant)
|
|
1814
|
+
except Exception:
|
|
1815
|
+
cleaned = raw_assistant
|
|
1816
|
+
after = (int(stats.get("tag_stripped", 0) or 0),
|
|
1817
|
+
int(stats.get("json_stripped", 0) or 0),
|
|
1818
|
+
int(stats.get("fenced_stripped", 0) or 0))
|
|
1819
|
+
removed_tags = max(0, sum(after) - sum(before))
|
|
1820
|
+
sanitized = cleaned != raw_assistant or removed_tags > 0
|
|
1821
|
+
assistant_text = cleaned
|
|
1798
1822
|
switches = normalize_switches(data.get("switches"))
|
|
1799
1823
|
gate = gate_reply(assistant_text, {
|
|
1800
1824
|
"switches": switches, "user_text": data.get("user_text", "")})
|
|
1825
|
+
if do_sanitize and _sanitize is not None:
|
|
1826
|
+
try:
|
|
1827
|
+
gate = dict(gate)
|
|
1828
|
+
gate["text"] = _sanitize.clean(gate.get("text") or "")
|
|
1829
|
+
except Exception:
|
|
1830
|
+
pass
|
|
1801
1831
|
captured = capture(session_id, "assistant", assistant_text, store, {
|
|
1802
1832
|
"enabled": _as_bool(data.get("memoryEnabled",
|
|
1803
1833
|
switches.get("memoryEnabled", True)),
|
|
@@ -1818,6 +1848,9 @@ def dispatch(action, payload=None):
|
|
|
1818
1848
|
"captured": {"captured": bool(decision.get("capture")),
|
|
1819
1849
|
"entry_id": str(entry.get("id") or ""),
|
|
1820
1850
|
"reason": str(decision.get("reason") or "")},
|
|
1851
|
+
"clean_text": cleaned,
|
|
1852
|
+
"sanitized": bool(sanitized),
|
|
1853
|
+
"removed_tags": int(removed_tags),
|
|
1821
1854
|
"trace": trace,
|
|
1822
1855
|
"trace_file": trace_file,
|
|
1823
1856
|
}
|