@shendeguize/dsh-agent-sidecar 0.1.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/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- package/src/supervisor.ts +463 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI bypass-analysis panel (T5.10b, design §4.e.3 / §5.1 view 2): fully
|
|
3
|
+
* controlled presentation over an {@link AnalysisGlueState} — the owner
|
|
4
|
+
* (detail view) holds the AnalysisStore and passes state + intents down.
|
|
5
|
+
*
|
|
6
|
+
* Honesty posture (§5.3): the engine disclaimer (fallback copy when a
|
|
7
|
+
* settle never happened) renders with every result, truncation is called
|
|
8
|
+
* out per exchange, terminal errors carry the vocabulary code, and the
|
|
9
|
+
* capability-off state explains where to enable analysis instead of
|
|
10
|
+
* hiding the entry. Strings ride the main locale table (`analysis.*`).
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState } from 'react'
|
|
16
|
+
import type { ReactElement } from 'react'
|
|
17
|
+
import { t } from '../locales/index.ts'
|
|
18
|
+
import type { AnalysisExchange, AnalysisGlueState } from '../analysis-glue.ts'
|
|
19
|
+
import css from './analysis.module.css'
|
|
20
|
+
|
|
21
|
+
export interface AnalysisPanelProps {
|
|
22
|
+
/** Live `analysis.enabled` bit (settings scope); false disables intents. */
|
|
23
|
+
enabled: boolean
|
|
24
|
+
state: AnalysisGlueState
|
|
25
|
+
onStart: () => void
|
|
26
|
+
onFollowup: (question: string) => void
|
|
27
|
+
onStop: () => void
|
|
28
|
+
onClose: () => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Terminal failure code → locale key ('' falls back to the generic). */
|
|
32
|
+
const ERROR_KEYS: Record<string, Parameters<typeof t>[0]> = {
|
|
33
|
+
analysis_disabled: 'analysis.errDisabled',
|
|
34
|
+
analysis_unavailable: 'analysis.errUnavailable',
|
|
35
|
+
target_not_found: 'analysis.errTargetNotFound',
|
|
36
|
+
too_many_active: 'analysis.errTooManyActive',
|
|
37
|
+
timeout: 'analysis.errTimeout',
|
|
38
|
+
create_failed: 'analysis.errCreateFailed',
|
|
39
|
+
cancelled: 'analysis.errCancelled',
|
|
40
|
+
network_error: 'analysis.errNetwork',
|
|
41
|
+
request_timeout: 'analysis.errNetwork',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function errorText(code: string): string {
|
|
45
|
+
const key = ERROR_KEYS[code]
|
|
46
|
+
return key !== undefined ? t(key) : t('analysis.errGeneric', { code })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Retryable notice code → copy. */
|
|
50
|
+
function noticeText(code: string): string {
|
|
51
|
+
if (code === 'timeout') return t('analysis.noticeTimeout')
|
|
52
|
+
if (code === 'cancel_failed') return t('analysis.noticeCancelFailed')
|
|
53
|
+
return t('analysis.noticeNetwork')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Exchange(props: { exchange: AnalysisExchange }): ReactElement {
|
|
57
|
+
const { exchange } = props
|
|
58
|
+
return (
|
|
59
|
+
<div className={css['exchange']} data-testid="agent-sidecar-analysis-exchange">
|
|
60
|
+
<span className={css['exchangeLabel']}>
|
|
61
|
+
{exchange.question === null ? t('analysis.exchangeInitial') : t('analysis.followupLabel')}
|
|
62
|
+
</span>
|
|
63
|
+
{exchange.question !== null && <div className={css['question']}>{exchange.question}</div>}
|
|
64
|
+
<pre className={css['summary']}>
|
|
65
|
+
{exchange.summary === '' ? t('analysis.emptySummary') : exchange.summary}
|
|
66
|
+
</pre>
|
|
67
|
+
{exchange.truncated && (
|
|
68
|
+
<span className={css['truncated']}>{t('analysis.truncatedNotice')}</span>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The analysis panel. Pure render over props (state machine lives in glue). */
|
|
75
|
+
export function AnalysisPanel(props: AnalysisPanelProps): ReactElement {
|
|
76
|
+
const { enabled, state } = props
|
|
77
|
+
const [question, setQuestion] = useState('')
|
|
78
|
+
|
|
79
|
+
const conversationLive = state.phase === 'ready' || state.phase === 'answering'
|
|
80
|
+
const showStart = state.phase === 'idle' || state.phase === 'failed' || state.phase === 'stopped'
|
|
81
|
+
|
|
82
|
+
const submitFollowup = (): void => {
|
|
83
|
+
const q = question.trim()
|
|
84
|
+
if (q === '' || state.phase !== 'ready') return
|
|
85
|
+
setQuestion('')
|
|
86
|
+
props.onFollowup(q)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<section className={css['panel']} data-testid="agent-sidecar-analysis">
|
|
91
|
+
<div className={css['head']}>
|
|
92
|
+
<span className={css['title']}>{t('analysis.title')}</span>
|
|
93
|
+
<span className={css['spacer']} />
|
|
94
|
+
{conversationLive && (
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
className={css['textButton']}
|
|
98
|
+
disabled={!enabled}
|
|
99
|
+
onClick={props.onStop}
|
|
100
|
+
data-testid="agent-sidecar-analysis-stop"
|
|
101
|
+
>
|
|
102
|
+
{t('analysis.stop')}
|
|
103
|
+
</button>
|
|
104
|
+
)}
|
|
105
|
+
<button type="button" className={css['closeButton']} onClick={props.onClose}>
|
|
106
|
+
{t('analysis.close')}
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{!enabled && (
|
|
111
|
+
<div className={css['noteCard']} data-testid="agent-sidecar-analysis-disabled">
|
|
112
|
+
{t('analysis.disabledNote')}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{state.exchanges.map((exchange, index) => (
|
|
117
|
+
<Exchange key={index} exchange={exchange} />
|
|
118
|
+
))}
|
|
119
|
+
|
|
120
|
+
{state.phase === 'failed' && state.errorCode !== null && (
|
|
121
|
+
<div className={css['errorCard']} data-testid="agent-sidecar-analysis-error">
|
|
122
|
+
{errorText(state.errorCode)}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
{state.phase === 'stopped' && (
|
|
126
|
+
<div className={css['noteCard']}>{t('analysis.stopped')}</div>
|
|
127
|
+
)}
|
|
128
|
+
{state.noticeCode !== null && (
|
|
129
|
+
<div className={css['noticeBar']} data-testid="agent-sidecar-analysis-notice">
|
|
130
|
+
{noticeText(state.noticeCode)}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{enabled && showStart && (
|
|
135
|
+
<>
|
|
136
|
+
{state.phase === 'idle' && (
|
|
137
|
+
<div className={css['mutedLine']}>{t('analysis.idleHint')}</div>
|
|
138
|
+
)}
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
className={css['primaryButton']}
|
|
142
|
+
onClick={props.onStart}
|
|
143
|
+
data-testid="agent-sidecar-analysis-start"
|
|
144
|
+
>
|
|
145
|
+
{state.phase === 'idle' ? t('analysis.start') : t('analysis.restart')}
|
|
146
|
+
</button>
|
|
147
|
+
</>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{state.phase === 'requesting' && (
|
|
151
|
+
<div className={css['mutedLine']}>{t('analysis.requesting')}</div>
|
|
152
|
+
)}
|
|
153
|
+
{state.phase === 'answering' && (
|
|
154
|
+
<div className={css['mutedLine']}>{t('analysis.answering')}</div>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{conversationLive && (
|
|
158
|
+
<div className={css['followupForm']}>
|
|
159
|
+
<input
|
|
160
|
+
className={css['followupInput']}
|
|
161
|
+
value={question}
|
|
162
|
+
placeholder={t('analysis.followupPlaceholder')}
|
|
163
|
+
disabled={!enabled || state.phase !== 'ready'}
|
|
164
|
+
onChange={(event) => {
|
|
165
|
+
setQuestion(event.currentTarget.value)
|
|
166
|
+
}}
|
|
167
|
+
onKeyDown={(event) => {
|
|
168
|
+
if (event.key === 'Enter') submitFollowup()
|
|
169
|
+
}}
|
|
170
|
+
data-testid="agent-sidecar-analysis-question"
|
|
171
|
+
/>
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
className={css['textButton']}
|
|
175
|
+
disabled={!enabled || state.phase !== 'ready' || question.trim() === ''}
|
|
176
|
+
onClick={submitFollowup}
|
|
177
|
+
data-testid="agent-sidecar-analysis-followup"
|
|
178
|
+
>
|
|
179
|
+
{t('analysis.followupSubmit')}
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{(conversationLive || state.exchanges.length > 0) && (
|
|
185
|
+
<div className={css['disclaimer']} data-testid="agent-sidecar-analysis-disclaimer">
|
|
186
|
+
{state.disclaimer ?? t('analysis.disclaimerFallback')}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</section>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* AI bypass-analysis panel styles (design §4.e.3 / §5.1 view 2): every
|
|
3
|
+
* color rides a --dsw-alias-* design token with a neutral fallback — no
|
|
4
|
+
* theme colors are hardcoded as the only value. Same posture as
|
|
5
|
+
* dsh-tools.module.css.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
.panel {
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
gap: 8px;
|
|
12
|
+
min-width: 0;
|
|
13
|
+
padding: 10px 12px;
|
|
14
|
+
border-radius: 10px;
|
|
15
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
16
|
+
background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.02));
|
|
17
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.head {
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
gap: 8px;
|
|
24
|
+
min-width: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.title {
|
|
28
|
+
font-size: 13px;
|
|
29
|
+
font-weight: 600;
|
|
30
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.spacer {
|
|
34
|
+
flex: 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.closeButton,
|
|
38
|
+
.textButton {
|
|
39
|
+
flex: none;
|
|
40
|
+
font-size: 12px;
|
|
41
|
+
padding: 2px 8px;
|
|
42
|
+
border-radius: 6px;
|
|
43
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
44
|
+
background: transparent;
|
|
45
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.closeButton:hover,
|
|
50
|
+
.textButton:hover {
|
|
51
|
+
background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.04));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.primaryButton {
|
|
55
|
+
align-self: flex-start;
|
|
56
|
+
font-size: 12px;
|
|
57
|
+
font-weight: 600;
|
|
58
|
+
padding: 4px 12px;
|
|
59
|
+
border-radius: 6px;
|
|
60
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
61
|
+
background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.04));
|
|
62
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.primaryButton:disabled,
|
|
67
|
+
.textButton:disabled {
|
|
68
|
+
opacity: 0.5;
|
|
69
|
+
cursor: not-allowed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.mutedLine {
|
|
73
|
+
font-size: 12px;
|
|
74
|
+
line-height: 18px;
|
|
75
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Capability-off / terminal-error note. */
|
|
79
|
+
.noteCard {
|
|
80
|
+
font-size: 12px;
|
|
81
|
+
line-height: 18px;
|
|
82
|
+
padding: 8px 10px;
|
|
83
|
+
border-radius: 8px;
|
|
84
|
+
border: 1px dashed var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
85
|
+
background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.02));
|
|
86
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.errorCard {
|
|
90
|
+
font-size: 12px;
|
|
91
|
+
line-height: 18px;
|
|
92
|
+
padding: 8px 10px;
|
|
93
|
+
border-radius: 8px;
|
|
94
|
+
color: var(--dsw-alias-state-error-primary, #cf222e);
|
|
95
|
+
background: var(--dsw-alias-state-error-secondary, rgba(207, 34, 46, 0.08));
|
|
96
|
+
border: 1px solid var(--dsw-alias-state-error-secondary, rgba(207, 34, 46, 0.24));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* Non-terminal retryable notice. */
|
|
100
|
+
.noticeBar {
|
|
101
|
+
font-size: 12px;
|
|
102
|
+
line-height: 18px;
|
|
103
|
+
padding: 6px 10px;
|
|
104
|
+
border-radius: 8px;
|
|
105
|
+
color: var(--dsw-alias-state-warning-primary, #9a6700);
|
|
106
|
+
background: var(--dsw-alias-state-warning-secondary, rgba(154, 103, 0, 0.1));
|
|
107
|
+
border: 1px solid var(--dsw-alias-state-warning-secondary, rgba(154, 103, 0, 0.28));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.exchange {
|
|
111
|
+
display: flex;
|
|
112
|
+
flex-direction: column;
|
|
113
|
+
gap: 4px;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.exchangeLabel {
|
|
117
|
+
font-size: 11px;
|
|
118
|
+
font-weight: 600;
|
|
119
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.question {
|
|
123
|
+
font-size: 12px;
|
|
124
|
+
line-height: 18px;
|
|
125
|
+
padding: 6px 10px;
|
|
126
|
+
border-radius: 8px;
|
|
127
|
+
background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.04));
|
|
128
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
129
|
+
white-space: pre-wrap;
|
|
130
|
+
word-break: break-word;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.summary {
|
|
134
|
+
margin: 0;
|
|
135
|
+
font-size: 12px;
|
|
136
|
+
line-height: 18px;
|
|
137
|
+
padding: 8px 10px;
|
|
138
|
+
border-radius: 8px;
|
|
139
|
+
background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.02));
|
|
140
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
141
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
142
|
+
white-space: pre-wrap;
|
|
143
|
+
word-break: break-word;
|
|
144
|
+
font-family: inherit;
|
|
145
|
+
max-height: 320px;
|
|
146
|
+
overflow: auto;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.truncated {
|
|
150
|
+
font-size: 11px;
|
|
151
|
+
color: var(--dsw-alias-state-warning-primary, #9a6700);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* The §5.3 honesty line — always visible with results. */
|
|
155
|
+
.disclaimer {
|
|
156
|
+
font-size: 11px;
|
|
157
|
+
line-height: 16px;
|
|
158
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
159
|
+
border-top: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
|
|
160
|
+
padding-top: 6px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.followupForm {
|
|
164
|
+
display: flex;
|
|
165
|
+
gap: 6px;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.followupInput {
|
|
169
|
+
flex: 1;
|
|
170
|
+
min-width: 0;
|
|
171
|
+
font-size: 12px;
|
|
172
|
+
padding: 4px 8px;
|
|
173
|
+
border-radius: 6px;
|
|
174
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16));
|
|
175
|
+
background: var(--dsw-alias-bg-layer-0, #fff);
|
|
176
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.actionsRow {
|
|
180
|
+
display: flex;
|
|
181
|
+
align-items: center;
|
|
182
|
+
gap: 8px;
|
|
183
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI bypass-analysis data glue (T5.10b, design §4.e.3 / §5.1): a
|
|
3
|
+
* framework-free store per analysis conversation, driving the controlled
|
|
4
|
+
* AnalysisPanel over the `POST action` `analysis.*` trio (host contract:
|
|
5
|
+
* routes.ts handleAnalysis* over analysis.ts AnalysisEngine).
|
|
6
|
+
*
|
|
7
|
+
* Wire semantics this store encodes:
|
|
8
|
+
* - a settled engine result comes back with the HTTP status derived from
|
|
9
|
+
* its `errorCode` (timeout→504, too_many_active→429, create_failed→502,
|
|
10
|
+
* cancelled→200); api.ts postAction folds non-200 bodies into ApiError
|
|
11
|
+
* with `reason` = that code — so failures arrive here as ApiError and
|
|
12
|
+
* the cancelled-terminal arrives as a 200 body;
|
|
13
|
+
* - the write gate answers 403 `analysis_disabled` (fail-closed), an
|
|
14
|
+
* agents-less composition answers 501 `analysis_unavailable`, and a
|
|
15
|
+
* pre-analysis host answers 400 `unknown_action` (mapped to the same
|
|
16
|
+
* honest "unavailable" terminal);
|
|
17
|
+
* - a request timeout DISPOSES the engine session (terminal), a followup
|
|
18
|
+
* timeout KEEPS it (non-terminal notice; analysis.ts contract).
|
|
19
|
+
*
|
|
20
|
+
* Token honesty: this store dials analysis turns only on explicit user
|
|
21
|
+
* intent (start / followup / stop) — never automatically. The one
|
|
22
|
+
* automatic call is dispose()'s fire-and-forget cancel, which spends no
|
|
23
|
+
* tokens and only releases the engine session slot (F2).
|
|
24
|
+
*
|
|
25
|
+
* @module
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
isApiError,
|
|
30
|
+
postAction,
|
|
31
|
+
type AnalysisTargetKind,
|
|
32
|
+
type RequestOptions,
|
|
33
|
+
} from './api.ts'
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Wire mirror (host: analysis.ts AnalysisResult; hand-written, client
|
|
37
|
+
// program cannot import host modules).
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** Terminal fact about one analysis turn (host: analysis.ts). */
|
|
41
|
+
export type AnalysisOutcomeWire = 'completed' | 'failed' | 'cancelled' | 'timeout'
|
|
42
|
+
|
|
43
|
+
/** Body of a settled `analysis.request` / `analysis.followup` action. */
|
|
44
|
+
export interface AnalysisResultWire {
|
|
45
|
+
outcome: AnalysisOutcomeWire
|
|
46
|
+
analysisSessionId?: string
|
|
47
|
+
summary?: string
|
|
48
|
+
truncated: boolean
|
|
49
|
+
tokensHint?: number
|
|
50
|
+
errorCode?: string
|
|
51
|
+
detail?: string
|
|
52
|
+
disclaimer: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Analysis target selector (mirrors routes.ts AnalysisTargetRequest). */
|
|
56
|
+
export interface AnalysisTarget {
|
|
57
|
+
targetKind: AnalysisTargetKind
|
|
58
|
+
targetId?: string
|
|
59
|
+
question?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Envelope builders (exported pure for tests).
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export function analysisRequestEnvelope(target: AnalysisTarget): {
|
|
67
|
+
type: 'analysis.request'
|
|
68
|
+
targetKind: AnalysisTargetKind
|
|
69
|
+
targetId?: string
|
|
70
|
+
question?: string
|
|
71
|
+
} {
|
|
72
|
+
return {
|
|
73
|
+
type: 'analysis.request',
|
|
74
|
+
targetKind: target.targetKind,
|
|
75
|
+
...(target.targetId !== undefined ? { targetId: target.targetId } : {}),
|
|
76
|
+
...(target.question !== undefined && target.question.trim() !== ''
|
|
77
|
+
? { question: target.question }
|
|
78
|
+
: {}),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function analysisFollowupEnvelope(analysisSessionId: string, question: string): {
|
|
83
|
+
type: 'analysis.followup'
|
|
84
|
+
analysisSessionId: string
|
|
85
|
+
question: string
|
|
86
|
+
} {
|
|
87
|
+
return { type: 'analysis.followup', analysisSessionId, question }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function analysisCancelEnvelope(analysisSessionId: string): {
|
|
91
|
+
type: 'analysis.cancel'
|
|
92
|
+
analysisSessionId: string
|
|
93
|
+
} {
|
|
94
|
+
return { type: 'analysis.cancel', analysisSessionId }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// State shapes.
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Conversation phases. `failed` is terminal for the conversation (restart
|
|
103
|
+
* mints a new engine session); `stopped` is the user-cancelled terminal.
|
|
104
|
+
*/
|
|
105
|
+
export type AnalysisPhase =
|
|
106
|
+
| 'idle'
|
|
107
|
+
| 'requesting'
|
|
108
|
+
| 'ready'
|
|
109
|
+
| 'answering'
|
|
110
|
+
| 'stopped'
|
|
111
|
+
| 'failed'
|
|
112
|
+
|
|
113
|
+
/** One settled turn: the initial summary (question null) or a follow-up. */
|
|
114
|
+
export interface AnalysisExchange {
|
|
115
|
+
question: string | null
|
|
116
|
+
summary: string
|
|
117
|
+
truncated: boolean
|
|
118
|
+
tokensHint: number | null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface AnalysisGlueState {
|
|
122
|
+
phase: AnalysisPhase
|
|
123
|
+
analysisSessionId: string | null
|
|
124
|
+
exchanges: AnalysisExchange[]
|
|
125
|
+
/** Engine disclaimer verbatim; null before the first settle. */
|
|
126
|
+
disclaimer: string | null
|
|
127
|
+
/** Terminal failure code (phase 'failed'), else null. */
|
|
128
|
+
errorCode: string | null
|
|
129
|
+
/** Non-terminal notice code (kept session; retryable), else null. */
|
|
130
|
+
noticeCode: string | null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const INITIAL_STATE: AnalysisGlueState = {
|
|
134
|
+
phase: 'idle',
|
|
135
|
+
analysisSessionId: null,
|
|
136
|
+
exchanges: [],
|
|
137
|
+
disclaimer: null,
|
|
138
|
+
errorCode: null,
|
|
139
|
+
noticeCode: null,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The action deadline: the engine bounds one turn at ~60s (analysis.ts
|
|
144
|
+
* `turnTimeoutMs`), so the transport must outlive it to receive the
|
|
145
|
+
* engine's own timeout verdict instead of racing it.
|
|
146
|
+
*/
|
|
147
|
+
export const ANALYSIS_POST_TIMEOUT_MS = 75_000
|
|
148
|
+
|
|
149
|
+
/** ApiError reason codes that keep a live followup session usable. */
|
|
150
|
+
const RETRYABLE_FOLLOWUP_CODES = new Set([
|
|
151
|
+
'timeout', // engine turn timeout: session kept by contract
|
|
152
|
+
'request_timeout',
|
|
153
|
+
'network_error',
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
function failureCode(err: unknown): string {
|
|
157
|
+
if (isApiError(err)) {
|
|
158
|
+
// A pre-analysis host rejects the envelope as unknown_action — same
|
|
159
|
+
// honest terminal as an assembled-but-agents-less host.
|
|
160
|
+
return err.reason === 'unknown_action' ? 'analysis_unavailable' : err.reason
|
|
161
|
+
}
|
|
162
|
+
return 'network_error'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// The store.
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
export interface AnalysisStoreOptions {
|
|
170
|
+
postActionFn?: typeof postAction
|
|
171
|
+
timeoutMs?: number
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export class AnalysisStore {
|
|
175
|
+
private state: AnalysisGlueState = INITIAL_STATE
|
|
176
|
+
private readonly listeners = new Set<() => void>()
|
|
177
|
+
private readonly postActionFn: typeof postAction
|
|
178
|
+
private readonly timeoutMs: number
|
|
179
|
+
private disposed = false
|
|
180
|
+
|
|
181
|
+
constructor(options: AnalysisStoreOptions = {}) {
|
|
182
|
+
this.postActionFn = options.postActionFn ?? postAction
|
|
183
|
+
this.timeoutMs = options.timeoutMs ?? ANALYSIS_POST_TIMEOUT_MS
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
subscribe = (fn: () => void): (() => void) => {
|
|
187
|
+
this.listeners.add(fn)
|
|
188
|
+
return () => { this.listeners.delete(fn) }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
getState = (): AnalysisGlueState => this.state
|
|
192
|
+
|
|
193
|
+
private setState(patch: Partial<AnalysisGlueState>): void {
|
|
194
|
+
if (this.disposed) return
|
|
195
|
+
this.state = { ...this.state, ...patch }
|
|
196
|
+
for (const fn of [...this.listeners]) fn()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private post(body: Parameters<typeof postAction>[0]): Promise<unknown> {
|
|
200
|
+
const opts: RequestOptions = { timeoutMs: this.timeoutMs }
|
|
201
|
+
return this.postActionFn(body, opts)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Start one analysis (allowed from idle and from the terminal phases). */
|
|
205
|
+
async start(target: AnalysisTarget): Promise<void> {
|
|
206
|
+
const { phase } = this.state
|
|
207
|
+
if (this.disposed || phase === 'requesting' || phase === 'ready' || phase === 'answering') {
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
this.setState({ ...INITIAL_STATE, phase: 'requesting' })
|
|
211
|
+
try {
|
|
212
|
+
const result = (await this.post(analysisRequestEnvelope(target))) as AnalysisResultWire
|
|
213
|
+
if (this.disposed) {
|
|
214
|
+
// The view unmounted while the request was in flight: the engine
|
|
215
|
+
// session it just created has no owner left — release it so it
|
|
216
|
+
// cannot strand one of the engine's bounded session slots (F2).
|
|
217
|
+
const id = result?.analysisSessionId
|
|
218
|
+
if (result?.outcome === 'completed' && typeof id === 'string' && id !== '') {
|
|
219
|
+
this.fireCancel(id)
|
|
220
|
+
}
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
if (this.state.phase !== 'requesting') return
|
|
224
|
+
this.adoptResult(null, result)
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (this.disposed || this.state.phase !== 'requesting') return
|
|
227
|
+
// Request-path failures are all terminal: either the engine session
|
|
228
|
+
// was never created, or (engine timeout) it was already disposed.
|
|
229
|
+
this.setState({ phase: 'failed', errorCode: failureCode(err) })
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Ask a follow-up in the live analysis session. */
|
|
234
|
+
async followup(question: string): Promise<void> {
|
|
235
|
+
const id = this.state.analysisSessionId
|
|
236
|
+
const q = question.trim()
|
|
237
|
+
if (this.disposed || this.state.phase !== 'ready' || id === null || q === '') return
|
|
238
|
+
this.setState({ phase: 'answering', noticeCode: null })
|
|
239
|
+
// Widened re-reads: setState/stop() move the phase between awaits, and
|
|
240
|
+
// TS's property narrowing from the entry guard would misjudge that.
|
|
241
|
+
const phaseNow = (): AnalysisPhase => this.state.phase
|
|
242
|
+
try {
|
|
243
|
+
const result = (await this.post(analysisFollowupEnvelope(id, q))) as AnalysisResultWire
|
|
244
|
+
if (this.disposed || phaseNow() !== 'answering') return
|
|
245
|
+
this.adoptResult(q, result)
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (this.disposed || phaseNow() !== 'answering') return
|
|
248
|
+
const code = failureCode(err)
|
|
249
|
+
if (RETRYABLE_FOLLOWUP_CODES.has(code)) {
|
|
250
|
+
// The engine keeps the session on a turn timeout; transport-level
|
|
251
|
+
// failures are unknowable, so stay usable and let the user retry.
|
|
252
|
+
this.setState({ phase: 'ready', noticeCode: code })
|
|
253
|
+
} else {
|
|
254
|
+
this.setState({ phase: 'failed', errorCode: code })
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Release the analysis session (idempotent on the engine side). */
|
|
260
|
+
async stop(): Promise<void> {
|
|
261
|
+
const id = this.state.analysisSessionId
|
|
262
|
+
const { phase } = this.state
|
|
263
|
+
if (this.disposed || id === null || (phase !== 'ready' && phase !== 'answering')) return
|
|
264
|
+
try {
|
|
265
|
+
await this.post(analysisCancelEnvelope(id))
|
|
266
|
+
if (this.disposed) return
|
|
267
|
+
// An in-flight followup now settles against a stopped phase and is
|
|
268
|
+
// dropped by the phase recheck above.
|
|
269
|
+
this.setState({ phase: 'stopped', noticeCode: null })
|
|
270
|
+
} catch {
|
|
271
|
+
if (this.disposed) return
|
|
272
|
+
this.setState({ noticeCode: 'cancel_failed' })
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Fold one settled 200 result into the conversation. */
|
|
277
|
+
private adoptResult(question: string | null, result: AnalysisResultWire): void {
|
|
278
|
+
if (result.outcome === 'completed') {
|
|
279
|
+
this.setState({
|
|
280
|
+
phase: 'ready',
|
|
281
|
+
analysisSessionId: result.analysisSessionId ?? this.state.analysisSessionId,
|
|
282
|
+
exchanges: [
|
|
283
|
+
...this.state.exchanges,
|
|
284
|
+
{
|
|
285
|
+
question,
|
|
286
|
+
summary: result.summary ?? '',
|
|
287
|
+
truncated: result.truncated,
|
|
288
|
+
tokensHint: result.tokensHint ?? null,
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
disclaimer: result.disclaimer,
|
|
292
|
+
errorCode: null,
|
|
293
|
+
noticeCode: null,
|
|
294
|
+
})
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
// A 200 non-completed result is the engine's `cancelled` verdict (all
|
|
298
|
+
// other failure codes ride non-200 statuses); treat unknown shapes as
|
|
299
|
+
// terminal too — never invent a usable session.
|
|
300
|
+
this.setState({
|
|
301
|
+
phase: result.errorCode === 'cancelled' || result.outcome === 'cancelled'
|
|
302
|
+
? 'stopped'
|
|
303
|
+
: 'failed',
|
|
304
|
+
errorCode: result.errorCode ?? result.outcome,
|
|
305
|
+
disclaimer: result.disclaimer,
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Late settlements become no-ops; subscribers are dropped. Idempotent.
|
|
311
|
+
* A live engine session is released with a fire-and-forget cancel so
|
|
312
|
+
* unmount / session-switch remounts cannot strand `maxActiveSessions`
|
|
313
|
+
* slots until plugin unload (F2). Cancel is idempotent on the engine
|
|
314
|
+
* side; a transport failure is silently ignored (nothing to surface —
|
|
315
|
+
* the store is gone).
|
|
316
|
+
*/
|
|
317
|
+
dispose(): void {
|
|
318
|
+
if (this.disposed) return
|
|
319
|
+
const { analysisSessionId, phase } = this.state
|
|
320
|
+
this.disposed = true
|
|
321
|
+
this.listeners.clear()
|
|
322
|
+
if (analysisSessionId !== null && (phase === 'ready' || phase === 'answering')) {
|
|
323
|
+
this.fireCancel(analysisSessionId)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Fire-and-forget analysis.cancel (failures are deliberately silent). */
|
|
328
|
+
private fireCancel(analysisSessionId: string): void {
|
|
329
|
+
void this.post(analysisCancelEnvelope(analysisSessionId)).catch(() => {})
|
|
330
|
+
}
|
|
331
|
+
}
|